Skip to content
ohstrPublic

About

A CLI toolkit and relay server for the Nostr protocol.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

>_ ncli

Release CI Go Reference License: Unlicense

Built for humans and agents, a Nostr relay, and a command-line client toolkit for streaming, syncing, inspecting, querying, and mining events.

Features

  • ncli relay — Run a fast, low-level Nostr relay server
  • ncli relay context — Save, switch, and run relays by name, auto-creating new ones
  • ncli relay members/invites/roles — Manage a running relay's NIP-43 membership over NIP-98 HTTP
  • ncli relay stats/reindex/clear — Manage a running relay over NIP-98 HTTP
  • ncli apply — Stream, sync, or inspect events, with a live TUI and hot-reloading config
  • ncli find — Query events
  • ncli profile — Look up one identity as a readable card
  • ncli event — Verify, fetch, show, rebroadcast or unwrap any event, no identity needed
  • ncli ping — Check if relays/targets are reachable
  • ncli dump — Export events to JSON
  • ncli publish — Publish signed events to one or more relays
  • ncli prefs — Set default relays for find/dump/miner check/publish
  • ncli miner — Mine NIP-13 proof-of-work into an event, or verify it on published events
  • ncli bunker — Run as a NIP-46 remote signer
  • ncli signer — Sign for local processes over a unix socket, gated by a policy
  • ncli space — Create, find, and join NIP-53 meeting spaces, with voice and chat
  • ncli huddle — List the voice rooms a relay is hosting
  • ncli groups — Create and run NIP-29 relay-hosted groups
  • ncli blossom — Upload, fetch, and manage content on Blossom media servers
  • ncli id — Generate or inspect a Nostr keypair
  • ncli 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.

Installation

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 | sh

Detects 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/ncli

Windows (PowerShell):

irm https://ohstr.github.io/ncli/install.ps1 | iex

Installs to a per-user directory and updates your user PATH — no Administrator prompt.

go install:

go install github.com/ohstr/ncli/cmd/ncli@latest

Docker — no toolchain required:

# :latest tracks the newest release, :edge tracks main — see Docker section below.
docker run --rm ghcr.io/ohstr/ncli:latest --help

From source — see Development.

Check what you're running, and where ncli keeps its files:

ncli version

relay

Run 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_community

Generates 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: true
ncli relay --config examples/relay/full.yaml

Only 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/logs
ncli relay --config examples/relay/minimal.yaml

Once 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.yaml

With 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:5500

Require 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: 20

See 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: true

The 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.

Example configs

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)

relay context

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.

-c/--context: run a context directly, creating it if new

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.

relay members/invites/roles

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.yaml

NIP-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: false

See 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.

relay stats/reindex/clear

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.yaml

Every subcommand takes --json; requires nip11.privkey in --config. Poll ncli relay stats for reindex progress.

prefs

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 path

ncli prefs relays add, then ncli find with no -s at all

find

Query 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.net

ncli find resolving a nip-05 address and its recent notes

More 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.sock

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
      # 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: 5

profile

To 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.

event

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).

ping

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 --tui

ncli ping checking two relays' reachability

dump

Export 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

ncli dump exporting events to JSON

-o/--out is required (.json or .jsonp). --auth-identity works the same way it does for find -- see above.

publish

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 --json

Exits 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.example

apply

Stream, 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.yaml

sync — 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: both
ncli apply -f examples/apply/sync.yaml

inspect — 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: 10
ncli apply -f examples/apply/inspect.yaml

TUI

ncli 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 file
  • r — restart; editing and saving the spec file on disk also prompts a reload

ncli apply streaming live events into the TUI dashboard

ncli apply reconciling a local store against a relay (sync)

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 view
  • Ctrl+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 s to 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 apply inspecting matched events across relays and a local store

miner

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.json
2026-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

ncli miner mine finding a proof-of-work nonce

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 24

It 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.json

or 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 --json

It 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"

bunker

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 stop

ncli bunker pairing an app via nostrconnect://, approving one request with a remembered grant, rejecting another outside its scope

Approving 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.yaml

Everything 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.yaml

See 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.

signer

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) and tags. A tag value matches exactly, or as a prefix when it ends in *.
  • require: attestations lists 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_skew before a restart may still validate once if --state-dir was 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_relays and logout get no-op acks.
  • sign_event takes an optional params[1], a JSON array of attestation events.
  • Error strings start with denied: (policy) or invalid: (malformed request).
  • signer_status is 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 (and unix://) wherever you accept bunker://.
  • 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] of sign_event.
  • Treat denied: ... as a refusal, not a transport error.
  • In Go, nmilat/nipLS does 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.

space

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 mykey

Without 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 --json

See skills/ncli-space/SKILL.md.

huddle

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.example

Join 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/ncli

Output 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.

groups

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 friend

A 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.

blossom

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.example

upload/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 --add

See skills/ncli-blossom/SKILL.md for the full walkthrough, including the multi-server fan-out/fallback model and BUD-03 server-list publishing/discovery.

id

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 --json

The 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.

ncli id generating a keypair, then inspecting a nip-05 address

decode

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... --json

ncli decode resolving a npub to its hex pubkey

Agent skills

For 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

Configuration

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/.

Docker

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 -d

build/relay/.env is gitignored — never commit it.

Development

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 vhs

Building 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.

License

Unlicense — public domain.

About

A CLI toolkit and relay server for the Nostr protocol.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages