Abstract
This introductory post is intended for anyone interested in exploring programmatic interaction with Nostr.
In this tutorial, we’ll build a command-line tool in Rust to explore the Nostr protocol.
We’ll use the nostr-sdk crate for simplicity and modern API support.
We’ll start simple: just generating keys, and gradually add features: saving keys locally, posting notes to relays, fetching them back etc. By the end, you’ll have a small experimental Nostr CLI to play with and that you can expand upon for custom use cases.
We’ll start simple: just generating keys, and gradually add features: saving keys locally, posting notes to relays, fetching them back etc. By the end, you’ll have a small experimental Nostr CLI to play with and that you can expand upon for custom use cases.
NOTE This tutorial has been inspired by tools like nostrdm, that showcase the potential of Nostr as a distributed and decentralised infrastructure.
Prerequisites
- Basic knowledge of Nostr.
- Rust installed.
- (a little bit of) programming experience.
Step 0: Create new project
Run the following command to generate a new rust project via Cargo (rust package manager):
cargo new hello_nostr && cd hello_nostr
this will generate the following folder sctructure:
.
├── Cargo.lock
├── Cargo.toml
├── README.md
└── src
├── main.rs
Now put these dependencies inside the generated
Cargo.toml file:[dependencies]
nostr-sdk = "0.44"
tokio = { version = "1", features = ["full"] }
Step 1: Generate a Nostr Keypair
As per NIP-01, every Nostr user is identified by a public/private keypair, which is used as the identity for signing events using Schnorr signatures of secp256k1 keys.
So when we generate a keypair, the public key (npub) is your identity, and the private key (nsec) is your secret for signing events.
So when we generate a keypair, the public key (npub) is your identity, and the private key (nsec) is your secret for signing events.
In order to generate a key pair, you need to insert the following code inside
main.rs:use nostr_sdk::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Generate a new keypair (sec256k1 / Schnorr)
let keys = Keys::generate();
println!("Public key (npub): {}", keys.public_key().to_bech32()?);
println!("Secret key (nsec): {}", keys.secret_key().to_bech32()?);
Ok(())
}
Now run this code with
cargo run and observe how the keys are generated for you:Step 2: Save the Keypair Locally
To reuse your identity across runs, we’ll save it in a
Add
.env file (Nostr events require persistent keys, because the signature determines ownership).Add
dotenvy dependency in Cargo.toml file:[dependencies]
nostr-sdk = "0.44"
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"
Now update
main.rs with the code for saving the generated keys:use nostr_sdk::prelude::*;
use dotenvy::dotenv;
use std::env;
use std::fs;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
// Load or generate keys
let keys = if let Ok(nsec) = env::var("NOSTR_KEY") {
Keys::parse(&nsec)?
} else {
let keys = Keys::generate();
fs::write(".env", format!("NOSTR_KEY={}\n", keys.secret_key().to_bech32()?))?;
keys
};
println!("Public key (npub): {}", keys.public_key().to_bech32()?);
println!("Secret key (nsec): {}", keys.secret_key().to_bech32()?);
Ok(())
}
Running this code will create a local
.env file like this:NOSTR_KEY=nsec1<rest-of-key-here>
Step 3: Post a Note to a Pair of Relays
Once you have a persistent keypair, the next natural step is posting a text note to the Nostr network, as per NIP-10.
This involves:
This involves:
- creating a client
- connecting to one or more relays
- building a “text note” event
- sending it.
Add the following to your
main.rs:use nostr_sdk::prelude::*;
use dotenvy::dotenv;
use std::env;
use std::fs;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
// Load or generate keys
let keys = if let Ok(nsec) = env::var("NOSTR_KEY") {
Keys::parse(&nsec)?
} else {
let keys = Keys::generate();
fs::write(".env", format!("NOSTR_KEY={}\n", keys.secret_key().to_bech32()?))?;
keys
};
println!("Public key (npub): {}", keys.public_key().to_bech32()?);
// Create a client with your keys (signer)
let client = Client::new(keys.clone());
// Add relays — you can list multiple for redundancy / broader reach
client.add_relay("wss://relay.damus.io").await?;
client.add_relay("wss://relay.nostr.band").await?;
client.connect().await;
// Build a text‑note using EventBuilder
let builder = EventBuilder::text_note("Hello, Nostr from Rust CLI!");
// Send the note (signed automatically using your keys)
let output = client.send_event_builder(builder).await?;
println!("Event ID: {}", output.id().to_bech32()?);
println!("Sent to: {:?}", output.success);
println!("Not sent to (if any): {:?}", output.failed);
Ok(())
}
Now run this and observe the output:
This tells us that the note has been sent to 2 relays and we can confirm this
by searching for the user key or note id from a Nostr web explorer like primal.net:
Step 4: Fetch Notes from a Relay
Once you can post notes, the next step is fetching them.
This is where Nostr shines as a decentralized network: you can query multiple relays for events by pubkey, kind, or time range.
This is where Nostr shines as a decentralized network: you can query multiple relays for events by pubkey, kind, or time range.
Add the following code to
main.rs to fetch your own recent notes:use nostr_sdk::prelude::*;
use dotenvy::dotenv;
use std::env;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
// Load your keys (same as in sending example)
let keys = if let Ok(nsec) = env::var("NOSTR_KEY") {
Keys::parse(&nsec)?
} else {
panic!("NOSTR_KEY not found in .env");
};
let my_pubkey = keys.public_key();
// Create a client (no signer needed to fetch)
let client = Client::default();
// Add relays
client.add_relay("wss://relay.damus.io").await?;
client.add_relay("wss://relay.nostr.band").await?;
client.connect().await;
// Filter: only TextNote events from this user, limit to 10
let filter = Filter::new()
.kind(Kind::TextNote)
.authors(vec![my_pubkey])
.limit(10);
let events = client.fetch_events(filter, Duration::from_secs(10)).await?;
println!("Got {} events from you:", events.len());
for ev in events {
println!("---");
println!("Event ID (hex): {}", ev.id.to_hex());
println!("Created at: {}", ev.created_at);
println!("Content: {}", ev.content);
}
Ok(())
}
Run this and observe how your notes are returned from the relays:
Step 5: Subscribe or Stream Events (Live updates)
So far we’ve performed one-off fetches, but Nostr is designed for long-lived subscriptions where a client maintains a connection and receives new events as they appear.
This is how dashboards, bots, or feed readers work.
This is how dashboards, bots, or feed readers work.
Here’s how to listen to a continuous stream of events:
use nostr_sdk::prelude::*;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::default();
client.add_relay("wss://relay.damus.io").await?;
client.connect().await;
// Subscribe to incoming text notes
let filter = Filter::new()
.kind(Kind::TextNote)
.limit(50);
let mut stream = client.stream_events(filter, Duration::from_secs(30)).await?;
println!("Listening for events (Ctrl+C to exit)…");
while let Some(event) = stream.next().await {
println!("--- New Event ---");
println!("{}", event.as_json());
}
Ok(())
}
This code uses the standard subscription model to fetch events from relay and displays them:
Useful Resources
If you found this introduction to Nostr in Rust interesting and want to go deeper, here are some useful resources: