Built for humans and agents, a Nostr relay, and a command-line client toolkit for streaming, syncing, inspecting, querying, and mining events.
ncli relay— Run a fast, low-level Nostr relay serverncli relay context— Save, switch, and run relays by name, auto-creating new onesncli relay members/invites/roles— Manage a running relay's NIP-43 membership over NIP-98 HTTPncli relay stats/reindex/clear— Manage a running relay over NIP-98 HTTPncli apply— Stream, sync, or inspect events, with a live TUI and hot-reloading configncli find— Query eventsncli ping— Check if relays/targets are reachablencli dump— Export events to JSONncli publish— Publish signed events to one or more relaysncli prefs— Set default relays forfind/dump/miner check/publishncli miner— Mine NIP-13 proof-of-work into an event, or verify it on published eventsncli bunker— Run as a NIP-46 remote signerncli blossom— Upload, fetch, and manage content on Blossom media serversncli id— Generate or inspect a Nostr keypairncli decode— Decode any NIP-19 bech32 entity (npub/nsec/note/nprofile/nevent/naddr)
--json and -q/--quiet are global flags for scripted/agent use — see
AGENTS.md for the full output/error contract. Run
ncli --help for the full command tree.
AI agents:
Fetch https://ohstr.github.io/ncli/PROMPT.md
See Agent skills below for what that does.
macOS / Linux:
curl -fsSL https://ohstr.github.io/ncli/install.sh | shDetects your OS and CPU (amd64/arm64 — including 64-bit Raspberry Pi OS) and
installs to /usr/local/bin if writable, otherwise ~/.local/bin.
Homebrew (macOS/Linux):
brew install ohstr/tap/ncliWindows (PowerShell):
irm https://ohstr.github.io/ncli/install.ps1 | iexInstalls to a per-user directory and updates your user PATH — no
Administrator prompt.
go install:
go install github.com/ohstr/ncli/cmd/ncli@latestDocker — no toolchain required:
# :latest tracks the newest release, :edge tracks main — see Docker section below.
docker run --rm ghcr.io/ohstr/ncli:latest --helpFrom source — see Development.
Supported NIPs
| NIP | Description |
|---|---|
| 01 | Core event, filter, and subscription types |
| 05 | NIP-05 identity verification |
| 09 | Event deletion |
| 11 | Relay information document |
| 13 | Proof of work |
| 16 | Event treatment (regular/replaceable/ephemeral kinds) |
| 19 | Bech32-encoded entities: npub, nsec, note, nprofile, nevent, naddr |
| 26 | Event delegation |
| 33 | Parameterized replaceable events |
| 40 | Event expiration |
| 42 | Relay authentication |
| 43 | Relay membership — off by default; see relay members |
| 44 | Versioned encryption |
| 47 | Nostr Wallet Connect |
| 48 | Proxy tags |
| 49 | Encrypted private key storage |
| 50 | Search — people search, not note content; see below |
| 57 | Lightning zaps |
| 65 | Relay list metadata |
| 77 | Negentropy sync |
| 88 | Polls |
| 90 | Data Vending Machines |
| 98 | HTTP authentication |
| AA | Agent auth — requires relay membership; see agent_auth below |
| AZ | AltZap — zaps for energy-backed coins |
| B0 | Web bookmarks |
| B7 | Blossom media server lists |
| OA | Owner attestation — verified as part of agent auth |
Quickest way to get one running:
ncli relay -c bee_communityGenerates a minimal config (shape: examples/relay/minimal.yaml)
with a fresh identity and starts serving — see
relay context.
Want full control? Write your own config and pass it with --config —
every field, at default, commented: examples/relay/full.yaml
# examples/relay/full.yaml
port: 5500
nip11:
name: ncli-dev-relay
pubkey: 3c1db3dd55e2ff09ba5317dd8eec2339797e9e2ddf74591172735c47f3a2ad6e
contact: admin@example.com
description: "A community relay"
limitation:
auth_required: false
membership_required: false
max_limit: 555555
max_message_length: 1101005
max_subscriptions: 355
max_indexable_tags: 5
store: ./data/db/notes.db
logdir: ./data/logs
pow:
strict: false
min: 0
cache:
topZapped:
enabled: false
window: 24h
search:
enabled: true
readonly: false
host: http://localhost:7700
key: masterKey
index_name: ncli_events
batch_size: 100
max_ch_size: 1000
membership:
enabled: false
inviteTTL: 24h
inviteMaxUses: 1
publishAddRemoveEvents: false
agent_auth:
enabled: false
freshnessWindow: 120s
kindEnforcement: false
handshakeTimeout: 5s
pingInterval: 30s
pongTimeout: 60s
writeTimeout: 60s
outgoingBufferSize: 1024
maxConcurrentStoreTasks: 2048
verificationWorkers: 50
logs:
filename: ./data/logs/nrelay.log
maxSize: 100
maxBackups: 3
maxAge: 28
compress: truencli relay --config examples/relay/full.yamlOnly store and a pubkey (or a privkey, to derive one) are actually
required — logdir defaults to the current directory, and everything else
defaults too, down to this minimal config:
# examples/relay/minimal.yaml
port: 5500
nip11:
name: ncli-dev-relay
pubkey: 9b14dff44fb8d74e2b90d5ae1501b935e073a5245749458a3e261021646f4e11
contact: admin@example.com
store: ./data/db/notes.db
logdir: ./data/logsncli relay --config examples/relay/minimal.yamlOnce it's running, operate it with NIP-98-signed admin commands — see
relay stats/reindex/clear
below for the full set:
# live reindexer + verification-worker metrics
ncli relay stats --config examples/relay/full.yamlWith search enabled, --search on find/dump is a people search
(NIP-50), not event content.
# kind-1 notes from profiles matching "jack"
ncli find --search "jack" -k 1 -l 5 -s localhost:5500Require NIP-13 proof-of-work on published events with a pow: block —
min: 0 (the default) accepts everything, even a lying nonce tag; a
non-zero min is advertised via NIP-11 either way, and strict: true is
what actually rejects events, both under-difficulty ones and ones whose
nonce tag doesn't match reality:
pow:
strict: false # true rejects under-difficulty and self-contradictory nonce tags
min: 20 # required leading-zero-bit difficulty; 0 = no requirementSee examples/relay/pow.yaml for a relay with
this actually enforced (strict: true). More presets (auth-required,
membership, ephemeral, cache+search) live under
examples/relay/.
Managing more than one relay means retyping --config path/to/relay.yaml
on every stats/members/invites/... call. Save each relay's config
under a short name instead, and switch between them:
ncli relay context add bee_community ~/relays/bee-community.yaml
ncli relay context add outbox ~/relays/outbox.yaml
ncli relay context use bee_community
# starts the relay server using bee_community's config
ncli relay
# targets bee_community too, no --config needed
ncli relay stats
ncli relay members list
# list saved contexts, "*" marks the current one (same as `relay context list`)
ncli relay context
ncli relay context remove outbox--config, if given, always wins. Otherwise the current context, if one is
set, wins over any ncli.yaml/relay.yaml in the current directory.
ncli relay -c <name> runs a saved context without context use first.
If <name> doesn't exist, it creates one: a minimal config and a fresh
identity, saved to the vault under <name> (suffixed on a label
collision), under .ncli/relays/<name>/. Registered, but not switched to
as current.
-q/--json skip the confirmation. --identity <nsec-or-vault-label>
uses an existing identity instead of generating one. --context and
--config are mutually exclusive.
Same NIP-98-signed mechanism as relay stats/reindex/clear below,
gated on membership.enabled: true. Enroll members, hand out invite
codes, and define roles on a running relay:
# enroll a pubkey directly -- no invite code needed
ncli relay members add <pubkey> --role vip --config examples/relay/membership.yaml
# who's currently enrolled
ncli relay members list --config examples/relay/membership.yaml
# issue a code to hand out out-of-band (a signup email, a Discord invite)
ncli relay invites create --ttl 24h --max-uses 1 --config examples/relay/membership.yaml
# define a role
ncli relay roles create vip --label "VIP" --color 280 --config examples/relay/membership.yamlNIP-43 has no "delete role" event, so roles create re-run with the same
id supersedes it rather than a separate roles delete.
NIP-AA agent auth (optional, on top of membership) lets an agent key gain virtual membership from its owner's NIP-43 membership by presenting a valid NIP-OA "auth" tag credential during AUTH, without separately enrolling the agent key itself:
# requires nip11.limitation.membership_required (which itself requires
# membership.enabled and limitation.auth_required)
agent_auth:
enabled: true
freshnessWindow: "120s"
kindEnforcement: falseSee the commented-out block in
examples/relay/membership.yaml or the
fully-documented one in
examples/relay/full.yaml.
NIP-98-signed HTTP requests to an already-running relay, using the same
--config as the server:
# live reindexer + verification-worker metrics
ncli relay stats --config examples/relay/full.yaml
# trigger a live profile-search reindex (async, kind 0 only)
ncli relay reindex search --config examples/relay/full.yaml
# trigger a live zap-stats reindex
ncli relay reindex zaps --config examples/relay/full.yaml
# delete the profile search index on the live relay
ncli relay clear search --config examples/relay/full.yaml
# delete zap counters on the live relay
ncli relay clear zaps --config examples/relay/full.yamlEvery subcommand takes --json; requires nip11.privkey in --config.
Poll ncli relay stats for reindex progress.
A default relay list — find, ping, dump, miner check, and
publish all use it automatically whenever you don't pass your own
relays.
ncli prefs relays add relay.primal.net
ncli prefs relays list
# no -s -- falls back to the prefs relay list above
ncli find jack@primal.net -k 1 -l 2
ncli prefs relays remove relay.primal.net
# remove all of them
ncli prefs relays clear
# print where prefs.yaml lives
ncli prefs pathLooks up an event by ID and/or filter, stopping at the first matching
target (dump merges across every target instead). The identifier can be
an event (hex ID, note1.../nevent1...) or an author (npub1...,
nprofile1..., nip-05 name@domain) — author-only defaults to their
profile (kind 0).
# nip-05 address -> their profile (kind 0)
ncli find jack@primal.net -s relay.ohstr.com
# their 5 most recent notes
ncli find jack@primal.net -k 1 -l 5 -s relay.ohstr.com
# npub/nprofile/note1/nevent1 all work the same way
ncli find npub1...
# look up one event by ID, targets from a file
ncli find <event-id> -t examples/targets.yaml
# multiple relays, first match wins
ncli find --kinds 1 --limit 5 -s relay.ohstr.com,relay.primal.netMore filter flags (-a/--authors, -i/--ids, --since/--until/
--search/--tag) live behind ncli find --help.
A --targets file isn't just a relay list — it can carry filters too, so
a single YAML file replaces both -s and the inline filter flags at once
(an author given as the positional identifier, like below, still ANDs in
on top of the file's own filters):
ncli find jack@primal.net -t examples/targets.yaml# examples/targets.yaml
kind: targets
spec:
relays:
- wss://relay.ohstr.com
- relay: wss://relay.snort.social
trusted: true # skip re-verifying this source's signatures (default false)
- path: ./data/db/notes.db
ensure: create # create if missing (default "exists", errors if missing)
# filters (optional, omit entirely to match everything): same NIP-01
# fields as examples/filters.yaml. Multiple filters are OR'd together;
# within one filter every field set must match (AND).
filters:
- kinds: [1]
limit: 5Confirms a relay actually speaks the protocol, not just accepts a connection. Fetches no events, and exits non-zero if any relay is unreachable.
Results narrate as plain log lines by default. Pass --tui for a live
interactive board instead -- only takes effect in a real terminal and
without --json/--quiet, falling back to plain narration otherwise.
# relays are plain arguments, no flag needed -- scheme optional too
ncli ping relay.primal.net relay.snort.social
# relays from a file (same shape as find/dump's --targets)
ncli ping -t examples/targets.yaml
# every `ncli prefs` relay
ncli ping
# structured report on stdout, for scripting
ncli ping relay.primal.net --json
# live interactive board instead of plain log lines
ncli ping relay.primal.net relay.snort.social --tuiSame targets/filters as find, but merges results across every
target instead of stopping at the first match.
# export a relay's events to JSON
ncli dump -s relay.ohstr.com -o events.json
# merge a relay + a local store
ncli dump -s relay.primal.net,./data/db/notes.db -o out.json
# recent notes, every `ncli prefs` relay
ncli dump -k 1 --since 24h -o recent.json
# relays + filters from one YAML file
ncli dump -t examples/targets.yaml -o out.json-o/--out is required (.json or .jsonp).
The write-side counterpart to dump: sends signed events to relays and
reports each one's OK.
# mine + auto-sign, then publish
ncli miner mine --content "hello from ncli" --identity mykey -d 24 -o signed.json
ncli publish -e signed.json -s wss://relay.ohstr.com
# publish several events to several relays at once
ncli publish -e events.json -s relay.ohstr.com,relay.snort.social --jsonExits non-zero if any (event, relay) pair fails — the same
composes-into-CI/scripts convention as miner check.
ncli apply -f <file> runs one of three workflows, chosen by the file's
kind. Fully annotated versions of each live under
examples/apply/ — the snippets below are trimmed to
the essentials.
--strict-pow (default false) makes apply reject an untrusted event
whose nonce tag doesn't meet its declared PoW difficulty, instead of
accepting it — settable via the flag or the spec's own strictPow: true
field, flag wins when passed explicitly.
See skills/ncli-apply/SKILL.md for the full
walkthrough, including trusted: true exemptions and relay-side PoW
enforcement.
stream — tail events live, forwarding everything matching filters
from every from flow to every to flow until interrupted (any number of
sources and destinations, mixed relays/stores):
kind: stream
spec:
from:
- relay: "wss://relay.ohstr.com"
trusted: true # skip re-verifying this source's signatures (default false)
- wss://relay.snort.social # bare-string shorthand for a remote relay also works
to:
- path: "./data/mirror.db"
ensure: create
filters:
- kinds: [1]ncli apply -f examples/apply/stream.yamlsync — NIP-77 negentropy reconciliation between exactly one local
store and one remote relay (direction: both|up|down):
kind: sync
spec:
from:
type: local
path: "./data/db/notes.db"
ensure: create
to:
relay: "wss://relay.ohstr.com"
trusted: true # skip re-verifying this source's signatures (default false)
direction: bothncli apply -f examples/apply/sync.yamlinspect — read-only query across any number of targets, any mix of
relays/stores; never publishes or writes to a target:
kind: inspect
spec:
targets:
- "wss://relay.primal.net"
- "wss://relay.snort.social"
- path: "./data/db/notes.db"
ensure: create
filters:
- kinds: [1]
limit: 10ncli apply -f examples/apply/inspect.yamlncli apply opens a full-screen terminal dashboard by default — a live,
sortable table of per-flow metrics (events, errors, throughput) next to a
live log pane, for stream/sync. Some keys:
Tab/Shift+Tab— move focus between panels- a column's highlighted letter — sort by that column
d— remove a flow from the running stream/sync (with confirmation)Ctrl+S— snapshot the current spec (incl. any live edits) to a new YAML filer— restart; editing and saving the spec file on disk also prompts a reload
inspect uses a different layout: a compact Targets strip, the ambient
log, then a full-width table of matched events anchored at the bottom.
Some keys:
Enter— open the selected event in a scrollable, read-only JSON detail viewCtrl+S— save the selected event to a local JSON file, from the table or the detail view; repeated saves in a session append to the same file (<spec-file>-events-<timestamp>.json)w/s— Wrap/Autoscroll toggle for whichever panel is focused (the events table only has Autoscroll — its cells are always single-line, so open the detail view for the full value)- any arrow/Page/Home/End move pins the selection and turns off
Autoscroll; press
sto resume following the tail
Add raw: true to a stream spec to skip the TUI and just log to stdout —
useful when piping output or running under a process supervisor.
ncli miner mine finds a NIP-13 proof-of-work nonce for an event, searching
across every CPU core by default. Author the note inline with
--content/--content-file (fills in created_at/kind for you) plus
--identity for the pubkey — no hand-written event file needed:
ncli miner mine --content "hello from ncli" --tag t=nostr --identity mykey -d 24 -o mined.json2026-07-08T23:31:27Z INF mining... 1,804,600 hashes tried, 300ms elapsed, 6.01 MH/s across 8 worker(s)
2026-07-08T23:31:27Z INF mining... 2,843,604 hashes tried, 500ms elapsed, 6.31 MH/s across 8 worker(s)
2026-07-08T23:31:27Z INF id: 000000530da8dc65159359f6ef14118d6a82ee2d337660f3ad8fbc60e57b5073
2026-07-08T23:31:27Z INF nonce: 3641608
2026-07-08T23:31:27Z INF difficulty: 24
If --identity resolves to a private key, the mined event is signed
automatically — ready for ncli publish as-is. A pubkey-only identity
mines but can't sign.
For anything --content/--content-file can't express, pass a
structured event file instead:
# examples/event.yaml
pubkey: 3c1db3dd55e2ff09ba5317dd8eec2339797e9e2ddf74591172735c47f3a2ad6e
created_at: 1719759720 # <-- replace with the current unix time, e.g. `date +%s`
kind: 1
tags:
- ["t", "nostr"]
content: "hello from ncli"ncli miner mine -e examples/event.yaml -o mined.json -d 24It never overwrites -e's file unless you pass --in-place. --json
reports the mined result as structured JSON.
ncli miner check verifies PoW on already-mined events — from a file:
ncli miner check -e events.jsonor fetched live (same filters as above), optionally narrowed to one identity's own events — a one-liner PoW compliance audit:
ncli miner check -s relay.ohstr.com --identity mykey --kinds 1 --since 7d --jsonIt exits non-zero the moment any checked event fails, so it composes directly into CI/cron:
ncli miner check -e events.json || alert-oncall "PoW compliance regression"Turns ncli into a NIP-46 remote signer: your private key never leaves
this process. Other Nostr apps send it encrypted signing requests over a
relay; you approve or reject each one from a live TUI, or a remembered
grant handles it automatically.
ncli bunker --identity mykey --relay wss://relay.example
# reattach from another terminal
ncli bunker attach
# {"running", "identity_pub", "relays", "pending_count", "session_count"}
ncli bunker status --json
# every app with a remembered permission
ncli bunker sessions list
ncli bunker sessions revoke <pubkey>
# recently resolved requests, most recent first
ncli bunker history
ncli bunker stopApproving a request can grant an "Always" permission instead of a one-off decision, and on Linux/macOS the TUI can detach into a background daemon so other apps stay connected.
Pairing works both directions other bunker apps expect (bunker:// and
nostrconnect://), from inside the TUI (c) or scripted:
# prints a bunker:// URI to paste into a client
ncli bunker connect
ncli bunker connect "nostrconnect://..."
# pre-authorize the app, no prompts
ncli bunker connect --grants examples/bunker/agent.yamlSee skills/ncli-bunker/SKILL.md for the
full walkthrough, including the Windows platform gap, the grants-spec
format, and pairing an AI agent for unattended signing.
A client for the Blossom protocol: content-addressed blob storage authenticated with your Nostr identity instead of a login.
ncli blossom servers add https://blossom.example --identity mykey --publish
ncli blossom upload photo.jpg --identity mykey
ncli blossom list --identity mykey
ncli blossom download <hash> -o photo.jpg
ncli blossom rm <hash> --identity mykey --yesupload/rm/mirror fan out to every configured server and report a
result per (item, server) pair; download tries them in order and stops
at the first that answers. Any command taking a target identity also
accepts someone else's, not just your own:
# someone else's media, not just your own
ncli blossom list alice@example.com
# find (and adopt) their published servers
ncli blossom servers discover alice@example.com --addSee skills/ncli-blossom/SKILL.md for the
full walkthrough, including the multi-server fan-out/fallback model and
BUD-03 server-list publishing/discovery.
No argument generates a new keypair (hex, nsec, npub); an argument resolves/inspects an existing one.
# generate an identity, save it to the local vault
ncli id --save --label mykey
# inspect an identity by npub
ncli id npub1...
# ...or a saved vault label, with privkey/nsec
ncli id mykey --reveal
# list every saved vault identity
ncli id list --reveal
# non-interactive, vault password from NCLI_VAULT_PASSWORD
ncli id --json --save --label mykey
# sign an unsigned event with a saved identity, ready for `ncli publish`
ncli id sign --identity mykey -e draft.json -o signed.json
# mint a NIP-26 delegation token from two saved identities
ncli id delegate --issuer mykey --delegatee otherkey --jsonThe vault is encrypted under a password. ncli id sign signs an unsigned
event with an identity's private key; ncli id delegate mints a NIP-26
delegation token from one.
# npub/nsec/note/nprofile/nevent/naddr -> hex fields
# (+ relay hints/kind if embedded)
ncli decode npub1...
# same, as structured JSON
ncli decode npub1... --jsonFor coding agents: AGENTS.md points to the matching skill
under skills/ (one per command group, usable standalone with
just the ncli binary on PATH).
# any agentskills.io-compatible agent
npx skills add ohstr/ncli --all -y
# Claude Code only
/plugin marketplace add ohstr/ncli
# ...one command group at a time
/plugin install ncli-apply@ncli
Config is loaded via viper from a YAML
file or NCLI_-prefixed environment variables. Without --config, ncli
uses the current relay context
if one is set, otherwise it looks for ncli.yaml/relay.yaml in the
current directory, then $HOME. Every YAML input ncli accepts has a
documented sample under examples/.
Runs the published ghcr.io/ohstr/ncli image alongside Meilisearch via
build/relay/docker-compose.prod.yaml.
Meilisearch's port isn't exposed to the host and there's no default master
key — set one yourself:
cp build/relay/.env.example build/relay/.env
# edit build/relay/.env: set MEILI_MASTER_KEY, optionally RELAY_CONFIG
docker compose -f build/relay/docker-compose.prod.yaml --env-file build/relay/.env up -dbuild/relay/.env is gitignored — never commit it.
This project uses just as its task runner:
# go build -o bin/ncli ./cmd/ncli
just build
# go test -short -race ./... (skips live-relay tests)
just test
# vet + test
just check
# run the stream pipeline benchmarks
just bench
# run the relay server against examples/relay/minimal.yaml
just dev relay
# run the relay + Meilisearch in Docker
just dev up
# stop the Docker dev stack
just dev down
# regenerate this README's demo GIFs (needs vhs/ttyd/ffmpeg on PATH)
just vhsBuilding from source needs one extra one-time setup step — see CONTRIBUTING.md.
Releases are cut manually via the workflow_dispatch-only
.github/workflows/release.yml.
Unlicense — public domain.










