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 profile— Look up one identity as a readable cardncli event— Verify, fetch, show, rebroadcast or unwrap any event, no identity neededncli 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 signer— Sign for local processes over a unix socket, gated by a policyncli space— Create, find, and join NIP-53 meeting spaces, with voice and chatncli huddle— List the voice rooms a relay is hostingncli groups— Create and run NIP-29 relay-hosted groupsncli 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.
Check what you're running, and where ncli keeps its files:
ncli versionRun a Nostr relay server.
Supported NIPs
| NIP | Description |
|---|---|
| 01 | Basic protocol flow description |
| 05 | Mapping Nostr keys to DNS-based internet identifiers |
| 09 | Event Deletion Request |
| 11 | Relay Information Document |
| 13 | Proof of Work |
| 16 | Event Treatment |
| 19 | bech32-encoded entities |
| 26 | Delegated Event Signing |
| 29 | Relay-based Groups |
| 33 | Parameterized Replaceable Events |
| 40 | Expiration Timestamp |
| 42 | Authentication of clients to relays |
| 43 | Relay Access Metadata and Requests — off by default; see relay members |
| 44 | Encrypted Payloads (Versioned) |
| 47 | Nostr Wallet Connect |
| 48 | Bridged Events |
| 49 | Private Key Encryption (ncryptsec) |
| 50 | Search Capability — people search, not note content; see below |
| 53 | Live Streaming and Spaces |
| 57 | Lightning Zaps |
| 65 | Relay List Metadata |
| 71 | Video Events |
| 77 | Negentropy Syncing |
| 88 | Polls |
| 90 | Data Vending Machines |
| 98 | HTTP Auth |
| A0 | Voice Messages |
| AA | Agent auth — requires relay membership; see agent_auth below |
| AZ | AltZap — zaps for energy-backed coins |
| B0 | Web Bookmarks |
| B7 | Blossom |
| 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:
# true rejects under-difficulty and self-contradictory nonce tags
strict: false
# required leading-zero-bit difficulty; 0 = no requirement
min: 20See examples/relay/anti-spam-relay.yaml
for a relay with this actually enforced (strict: true).
Let people talk to each other through the relay — group voice calls, plus video
and screen sharing for browsers — with a huddle: block. Calls get their own
WebSocket endpoint, so a client connects to that as well as the Nostr socket:
nip11:
# required: a joining client's auth event names it
url: "wss://relay.example"
huddle:
enabled: true
# also serve browsers, which is what carries video and screen sharing
rtc: trueThe relay forwards audio without decoding it, so it links no codec. See
examples/relay/community-voice-relay.yaml
for the documented preset, and ncli huddle for joining a call
from a terminal.
Host NIP-29 groups with your own roles, and moderate the relay from any NIP-86 client:
groups:
roles:
admin: [9000, 9001, 9002, 9005, 9008, 9009, 9010]
moderator: [9005, 9010]
roleDescriptions:
moderator: keeps the peace
admins: [npub1...] # moderate every group, like the relay's own key
moderationLog: true # members can read who did what
pendingJoins: true # closed groups keep codeless joins for an admin
nip86:
enabled: true
admins: [<hex pubkey>]NIP-86 bans pubkeys and events, allows or disallows kinds, blocks IPs and
renames the relay (name, description, icon); that state is kept in
<store>.moderation.json. See
skills/ncli-relay-ops.
Every file under examples/relay/ is a working config,
commented top to bottom. Pick the scenario closest to yours and start from
it:
| File | For |
|---|---|
minimal.yaml |
the smallest config that runs |
full.yaml |
every field, at its default, documented |
personal-relay.yaml |
your own outbox/backup relay |
dev-test-relay.yaml |
a throwaway relay for local dev or CI |
public-search-relay.yaml |
a public relay with profile search and zap-ranked trending |
anti-spam-relay.yaml |
a public relay that requires proof-of-work |
accountability-relay.yaml |
anyone posts, but only signed in (NIP-42) |
community-membership-relay.yaml |
members only, joined by invite (NIP-43) |
family-private-relay.yaml |
a closed circle the operator enrolls by hand |
tracked-membership-relay.yaml |
open to all, with a member roster and roles |
enterprise-compliance-relay.yaml |
members + proof-of-work + NIP-86 admin, stacked |
agent-swarm-relay.yaml |
AI agents posting under their owner's membership (NIP-AA) |
app-backend-relay.yaml |
an app's backend administering and querying over HTTP |
community-voice-relay.yaml |
voice calls and meeting spaces (huddles) |
Save, switch, and run relays by name, instead of retyping --config path/to/relay.yaml on every stats/members/invites/... call:
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.
NIP-43 membership admin. 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/community-membership-relay.yaml
# who's currently enrolled
ncli relay members list --config examples/relay/community-membership-relay.yaml
# one member's record, then un-enroll them
ncli relay members show <pubkey> --config examples/relay/community-membership-relay.yaml
ncli relay members remove <pubkey> --config examples/relay/community-membership-relay.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/community-membership-relay.yaml
# every live code, then cancel one
ncli relay invites list --config examples/relay/community-membership-relay.yaml
ncli relay invites revoke <code> --config examples/relay/community-membership-relay.yaml
# define a role, and list them
ncli relay roles create vip --label "VIP" --color 280 --config examples/relay/community-membership-relay.yaml
ncli relay roles list --config examples/relay/community-membership-relay.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 examples/relay/agent-swarm-relay.yaml
for a relay built around this, or the commented-out block in
examples/relay/community-membership-relay.yaml
and the fully-documented one in
examples/relay/full.yaml.
Operate a running relay: 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.
Default relays for find/ping/dump/miner/publish — a default
relay list that find, ping, dump, miner check, and publish all
use 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 pathQuery events: looks 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.
--auth-identity (alias --signer) logs in (NIP-42) to a relay that
requires it: a socket signer (bunker+unix:///path.sock), a NIP-46
bunker://..., or a vault label/nsec. A read signs only the login, one
kind 22242 per connection. Omit it to query anonymously. A failed login
exits 7 with reason login_required, signer_refused or
relay_refused in --json.
ncli find --kinds 1 -s wss://relay.example --auth-identity mykey
ncli find --kinds 1 -s wss://relay.example --auth-identity bunker+unix:///run/signer/agent.sockA --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
# skip re-verifying this source's signatures (default false)
trusted: true
- path: ./data/db/notes.db
# create if missing (default), or "exists" to error if missing
ensure: create
# 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: 5To look someone up, reach for ncli profile rather than find -k 0 —
one query, four records, printed as a card instead of raw JSON:
ncli profile npub1...
ncli profile jack@primal.net
# a vault label works too
ncli profile mykey
# structured, for scripts
ncli profile npub1... --json ncli profile
jack
Bitcoin, Nostr, and open protocols.
Identity
nip-05 ✔ jack@example.com
npub npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m
pubkey 82129f882b6eab9a95d3f8f7c8556c873c1d2bc55cf7a250993e9553efdf3543
lightning ⚡ jack@example.com
website https://example.com
Following 1,284
Relays (3)
↕ wss://relay.example.com
↓ wss://read.example.com
↑ wss://write.example.com
Blossom (2)
• https://blossom.example.com
• https://cdn.example.org
queried 4 relay(s) · profile updated 2026-09-21
It gathers kind:0 (metadata), kind:3 (contacts → the following count),
kind:10002 (NIP-65 relays, ↕ read+write / ↓ read / ↑ write) and
kind:10063 (Blossom servers) in a single subscription, and aggregates
across every relay instead of stopping at the first match the way
find does — a relay list often lives somewhere other than the profile.
The claimed nip-05 is checked against its domain (✔ verified, ✘
mismatch, ⚠ unreachable); --no-verify skips that round trip. An
identity that published nothing still renders, with each section reading
not published, and exits 0.
Work with any event, whoever signed it, with no vault identity: name it
by an nevent/note/naddr, a hex id or a kind:pubkey:d coordinate, or
give it in full as JSON, -f files or stdin. It is looked up on the
reference's own relay hints, then -s (or ncli prefs relays), then the
author's NIP-65 write relays.
# is this event authentic? (exit 1 if not)
ncli event verify nevent1...
ncli event verify -f signed.json --author npub1... --min-pow 20 --offline
ncli event verify naddr1... --cross-relays --deleted --json
# the event as served, or as a readable card
ncli event get note1... | jq .content
ncli event show naddr1...
# where does it live, and copy it somewhere else unchanged
ncli event seen nevent1...
ncli event rebroadcast nevent1... --to wss://my.relay.example
# build, sign and publish in one pipe
ncli event new --content "hello" --pubkey me \
| ncli id sign -e - -o - --identity me \
| ncli publish -e -Also: encode (note/nevent/naddr with relay hints), id (compute ids of
unsigned events), thread (parents and direct replies), diff (two
versions), unwrap (decrypt a NIP-59 gift wrap with --identity) and
validate (the rule an nmilat relay applies to the event's kind).
Test relay connectivity: confirms 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 --tuiExport events to JSON: same 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). --auth-identity works the
same way it does for find -- see above.
Send events to relays: 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.
A relay that requires login (NIP-42) gets one kind 22242 per connection,
signed by --signer, or by --auth-signer when the login identity
differs (e.g. events someone else signed). The refused event is retried
once.
ncli publish -e signed.json --auth-signer bunker+unix:///run/signer/agent.sock -s wss://members.exampleStream, sync, inspect: 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.
--auth-signer logs in (NIP-42) to stream/sync relays that require it
(bunker+unix://, bunker://, or a key); only login events are signed.
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"
# skip re-verifying this source's signatures (default false)
trusted: true
# bare-string shorthand for a remote relay also works
- wss://relay.snort.social
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"
# skip re-verifying this source's signatures (default false)
trusted: true
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.
Mine and verify proof-of-work: 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
# <-- replace with the current unix time, e.g. `date +%s`
created_at: 1719759720
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"Run as 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
# one app's permissions; name it; drop one permission, or the whole app
ncli bunker sessions grants <pubkey>
ncli bunker sessions rename <pubkey> "my agent"
ncli bunker sessions revoke-grant <pubkey> --method sign_event --kind 1
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.yamlEverything the TUI does also works without a terminal, for scripts and
agents. With no TTY (or with --json), ncli bunker starts the signer in
the background and prints its status:
# start headless; prints the same JSON as `bunker status --json`
ncli bunker --identity mykey --relay wss://relay.example --json
# requests no grant covers, waiting for a decision
ncli bunker pending list --json
# approve once
ncli bunker pending approve <id>
# approve and remember it for 24h, like the TUI's "Always" button
ncli bunker pending approve <id> --always --for 24h
ncli bunker pending reject <id>
# add or replace an already-paired app's permissions
ncli bunker sessions set-grant <pubkey> --grants grants.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 local signer for processes on the same machine or pod: ncli holds the
key in memory and signs over a unix socket, as a policy allows. No relay,
no network listener. A sign round trip takes under a millisecond.
Built for AI agents: the key sits in a sidecar container, and the agent's tools ask it to sign. A prompt-injected agent can't read the key and can't sign what the policy forbids.
# key from an ncryptsec file (or --identity <vault label>)
ncli signer serve --socket /run/signer/agent.sock \
--identity file:/etc/signer/key.ncryptsec --password-file /etc/signer/password \
--policy examples/signer/agent-policy.yaml --state-dir /var/lib/signer
# sign through it; the key never enters this process
ncli id sign --signer bunker+unix:///run/signer/agent.sock -e draft.json -o signed.json
ncli publish -e draft.json --signer bunker+unix:///run/signer/agent.sock
# health probe: exits 6 when nothing answers
ncli signer status --socket /run/signer/agent.sock
# dry-run a policy in CI: exits 7 on a denial
ncli signer check --policy policy.yaml -e event.json --pubkey npub1...Policy (kind: signer-policy): rules are tried in order, the first
whose selector matches decides, and anything unmatched is denied.
kind: signer-policy
rules:
- name: chat
allow: {kinds: [9, 7, 1111]}
deny_matching: ['nsec1[02-9ac-hj-np-z]{58}']
rate: 60/m
- name: relay-auth
allow: {kinds: [22242], tags: {relay: ["wss://relay.example", "wss://relay.example/*"]}}
- name: repo-state
allow: {kinds: [30617, 30618]}
require:
attestations:
- name: maintainer-approval
kinds: [9]
authors_file: approvers.txt
max_age: 15m
binds: [{content: "approve {id}"}]
- name: everything-else
deny: {}- Selectors match on
methods,kinds,counterparts(for encrypt/decrypt) andtags. A tag value matches exactly, or as a prefix when it ends in*. require: attestationslists other signed events the request must carry, such as a human approval or a CI result. An attestation binds to the target in one of three ways:{tag: e, from: id},{tag: d, from: "tag:d"}, or{content: "approve {id}"}/{content_contains: ...}. Each attestation is single-use.- Built in, always on:
- An event whose content or tags contain the signer's own key is never signed.
- An attestation signed by the signer's own key never counts.
- An attestation created before the signer started never counts.
The full reference is in
skills/ncli-signer/references/policy.md.
Threat model. Any process that can open the socket is a client, so
the socket's file mode, owner and --allow-uid/--allow-gid are the
authentication.
| A compromised client can | It cannot |
|---|---|
| sign whatever the policy allows, up to the rate limits | read the key, which never crosses the socket |
| decrypt messages from the counterparts the policy lists | get the key into a signed event or an encrypted message |
| fill the decision log or keep the signer busy | satisfy an attestation, which needs another key |
| replay an attestation, within a run or across restarts |
Residual risks:
- If another process runs as the signer's uid or shares its PID
namespace, it can read the key from memory. Use a separate container,
a separate uid and
shareProcessNamespace: false. - An approver's key is as powerful as the rule it unlocks.
- An attestation made within
max_clock_skewbefore a restart may still validate once if--state-dirwas lost, so keep the skew small on rules that need attestations. - Unscoped auth kinds (22242, 27235) let a client log in as the signer
anywhere. Scope them with
tags.
Protocol. The NIP-46 method set (get_public_key, sign_event,
nip04_*, nip44_*, ping) is sent as one JSON object per line:
{"id","method","params"} in, {"id","result"|"error"} out. There is
no kind-24133 wrapper and no encryption.
connect,get_relays,switch_relaysandlogoutget no-op acks.sign_eventtakes an optionalparams[1], a JSON array of attestation events.- Error strings start with
denied:(policy) orinvalid:(malformed request). signer_statusis ncli's own addition.
Failure modes:
| Situation | Result |
|---|---|
| Signer down | network (exit 6) |
| Request denied | auth (exit 7) with the rule's reason |
| Bad policy at start | serve exits |
Bad policy on SIGHUP or --watch |
old policy kept |
| Used set can't be written | request denied |
Every decision goes to stdout as one JSON line, with no content or key
material. --denials-file collects denials alone.
For client authors (ngit, buzz CLI, buzz-acp, ...):
- Accept
bunker+unix:///abs/path.sock(andunix://) wherever you acceptbunker://. - Dial the path and write NIP-46 request JSON, one per line, without encrypting it or wrapping it in an event. Read one response line per request.
- To pass attestations, send them as
params[1]ofsign_event. - Treat
denied: ...as a refusal, not a transport error. - In Go,
nmilat/nipLSdoes all of this (nipLS.Dial) and serves it (nipLS.NewServer). The protocol is NIP-LS.
examples/signer/ has a full agent policy and a Kubernetes pod manifest
with the sidecar.
See skills/ncli-signer/SKILL.md.
A meeting space (NIP-53, kind:30312) is a published record of where a meeting lives. It lasts even when nobody is in it, and joining it opens its voice call (a huddle) plus its chat.
ncli space create standup --summary "Daily sync" --relay wss://relay.example --identity mykey
ncli space list --relay wss://relay.example
ncli space show standup --relay wss://relay.example
# join the call: live roster, who is speaking, and the chat
ncli space join standup --relay wss://relay.example --identity mykeyWithout a terminal (or with --json), space join streams the call as
JSON lines instead (joined, participant_joined/left,
speaking_started/stopped, chat, ended) until --duration or an
interrupt. The chat also works on its own, without joining:
ncli space join 30312:<pubkey>:standup --relay wss://relay.example --duration 60s --json
ncli space chat send 30312:<pubkey>:standup "hello" --relay wss://relay.example --identity mykey
ncli space chat list 30312:<pubkey>:standup --relay wss://relay.example --jsonSee skills/ncli-space/SKILL.md.
The voice rooms behind spaces, hosted by the relay itself. Enable the
huddle: block (shape:
examples/relay/community-voice-relay.yaml),
then see which rooms have someone in them:
ncli huddle list --relay wss://relay.exampleJoin a room with ncli space join. The view shows who is in the room and who is speaking, read from the level
telemetry every audio frame already carries. It never captures a microphone, so
joining puts no audio into the room.
Two endpoints reach the same rooms: /huddle/{id}/audio carries Opus over a
plain WebSocket, and /huddle/{id}/rtc is a WebRTC SFU that also carries video
and screen share. The same room id is the same call through either. The relay
forwards Opus without decoding it, so it links no codec.
huddle.enabled requires nip11.url — a joining client's NIP-42 event names
the relay, and the endpoint validates against it. huddle.allowedOrigins is
empty by default, which allows any origin: admission is gated by a signed
challenge, so Origin is not the security boundary, and refusing on it would
only lock out browsers while every CLI client kept working.
Hearing the call needs a build with audio output:
# macOS, Windows
go build -tags huddleaudio ./cmd/ncli
# Linux, needs libasound2-dev
CGO_ENABLED=1 go build -tags huddleaudio ./cmd/ncliOutput needs cgo and ALSA on Linux and the release binaries are built without
cgo, so it is not in the default build. Without the tag the roster still works
and the status line says watching only.
NIP-29 groups hosted by the relay: private and closed by default, joined by invite code, with admins, roles, pins, and subgroups.
ncli groups create standup --relay wss://relay.example --identity mykey
ncli groups edit standup --name "Standup" --about "Daily sync" --relay wss://relay.example --identity mykey
# an invite code for someone to join with
ncli groups invite standup --relay wss://relay.example --identity mykey
ncli groups join standup --invite-code <code> --relay wss://relay.example --identity friend
# groups this identity belongs to
ncli groups list --mine --relay wss://relay.example --identity mykey
ncli groups show standup --relay wss://relay.example --identity mykey
# every visible group, as a parent/child tree
ncli groups tree --relay wss://relay.example
# admins: add or remove a member (optionally with roles)
ncli groups members add standup <pubkey> moderator --relay wss://relay.example --identity mykey
ncli groups members remove standup <pubkey> --relay wss://relay.example --identity mykey
# replace the pinned list (passing nothing clears it)
ncli groups pins set standup --event <event-id> --relay wss://relay.example --identity mykey
# delete one message from the group, or the whole group
ncli groups delete-event standup <event-id> --relay wss://relay.example --identity mykey
ncli groups delete standup --relay wss://relay.example --identity mykey
# a member leaving
ncli groups leave standup --relay wss://relay.example --identity friendA private group is invisible without --identity: list silently leaves it
out, and show is refused (auth). On a relay with groups.pendingJoins, a
join to a closed group without a code waits for an admin to members add
you. See
skills/ncli-groups/SKILL.md.
Upload, fetch, and manage media: 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 --yes
# copy a blob from another URL onto your servers
ncli blossom mirror https://other.example/<hash> --identity mykey
# report a blob to its server (BUD-09)
ncli blossom report <hash> --type spam --reason "bot spam" --identity mykey
# your default servers; drop one
ncli blossom servers list
ncli blossom servers remove https://blossom.exampleupload/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.
Generate or inspect a Nostr identity: 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 (prints only the public key)
ncli id --save --label mykey
# same, and also print the new private key
ncli id --save --label mykey --reveal
# 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
# save an existing key (nsec, hex or ncryptsec), read from stdin or --file
ncli id import --label mykey < key.txt
# rename a saved identity
ncli id relabel mykey work
# remove a saved identity (asks first)
ncli id rm work
# 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.
Decode a NIP-19 entity.
# 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).
# from the ncli binary itself, matching its version, into your agent's skills dir
ncli skills install --dir ~/.agents/skills
# 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.










