pull down to refresh

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.
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.
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 .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:
  • 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.
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.
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: