The outbox exists to re-send what a failed connection lost, but a failed connection reached it as the relay's refusal: onCannotConnect answered every pending event with newResponse(false), and since #4589 any OK ends delivery. So one failed dial, or a reset under an open socket, dropped every event outstanding at that relay, and nothing told the publisher: publishStream waited out its idle timeout. Measured on vespa-relay: a relay killed mid-backfill and back in 5s cost the sync's writer its whole 2,000-event window and ~100s of stall before the band was re-walked; reproduced over real sockets with 10 events that were never re-sent to the relay that came back. A failed connection is now not a verdict. PoolEventOutboxState.cannotConnect keeps the event pending, to be re-sent on the next connection, and gives up on the relay only at MAX_CONNECT_FAILURES (10) in a row, about thirteen minutes under the doubling reconnect back-off. PoolEventOutbox. onCannotConnect returns what it gave up on, and NostrClient reports those as onEventGaveUp, which publishStream already settles as DISCONNECTED. Any OK, true or false, still ends delivery. Tests: an event in flight when its socket fails stays pending, is re-sent on the new socket and ended by the OK there (fails on main: "still pending" expected 1, was 0); failed connections up to the budget give nothing up; the last one gives up on that relay only and returns the events; an OK after a failed connection still decides. CLIENT.md states the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxUUtjeM7vwdi7Gb8Uv2xP
29 KiB
Building a Nostr Client with Quartz
This guide walks you through building a Nostr client using Quartz's NostrClient and Ktor as the WebSocket transport layer. On JVM and Android you can skip the transport and use the bundled OkHttp one: NostrClient(BasicOkHttpWebSocket.Builder { okHttpClient }) (nip01Core.relay.sockets.okhttp).
Using a coding agent (Claude Code, Codex, Cursor, …)? Install the Quartz agent skill so it reaches for Quartz's helpers instead of re-implementing them.
Overview
Quartz provides a complete, transport-agnostic client engine:
| Component | Class | Role |
|---|---|---|
| NostrClient | com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient |
Manages relay connections, subscriptions, and event publishing |
| WebsocketBuilder | com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder |
Factory interface for pluggable WebSocket transports |
| Filter | com.vitorpamplona.quartz.nip01Core.relay.filters.Filter |
NIP-01 subscription filters |
| KeyPair | com.vitorpamplona.quartz.nip01Core.crypto.KeyPair |
Schnorr key pair for signing events |
| NostrSignerInternal | com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal |
Signs events with a local private key |
NostrClient doesn't know about HTTP or WebSockets. You provide a WebsocketBuilder that creates transport connections, and the client handles relay pooling, subscription management, reconnection, and event delivery.
Quick Start
1. Add Dependencies
In your build.gradle.kts:
dependencies {
// Ktor WebSocket client
implementation("io.ktor:ktor-client-core:3.1.1")
implementation("io.ktor:ktor-client-cio:3.1.1")
implementation("io.ktor:ktor-client-websockets:3.1.1")
// Quartz from Maven Central (check the latest version there)
implementation("com.vitorpamplona.quartz:quartz:1.19.0")
}
2. Implement the Ktor WebSocket Transport
Quartz uses two interfaces for WebSocket connectivity:
// WebSocket — a single connection to a relay
interface WebSocket {
fun needsReconnect(): Boolean
fun connect()
fun disconnect()
fun send(msg: String): Boolean
}
// WebsocketBuilder — factory that creates WebSocket instances
interface WebsocketBuilder {
fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket
}
// WebSocketListener — callbacks from the transport layer
interface WebSocketListener {
fun onOpen(pingMillis: Int, compression: Boolean)
fun onMessage(text: String) // must deliver messages in order
fun onClosed(code: Int, reason: String)
fun onFailure(t: Throwable, code: Int?, response: String?)
}
Here's a complete Ktor implementation:
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
class KtorWebSocket(
private val url: NormalizedRelayUrl,
private val client: HttpClient,
private val out: WebSocketListener,
) : WebSocket {
private var session: DefaultClientWebSocketSession? = null
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun needsReconnect() = session == null
override fun connect() {
job = scope.launch {
try {
client.webSocket(url.url) {
session = this
out.onOpen(0, false)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
out.onMessage(frame.readText())
}
}
} finally {
val reason = closeReason.await()
session = null
out.onClosed(
reason?.code?.toInt() ?: 1000,
reason?.message ?: "Connection closed",
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
session = null
out.onFailure(e, null, e.message)
}
}
}
override fun disconnect() {
job?.cancel()
job = null
session = null
}
override fun send(msg: String): Boolean {
val currentSession = session ?: return false
return try {
runBlocking { currentSession.send(Frame.Text(msg)) }
true
} catch (e: Exception) {
false
}
}
class Builder(
private val client: HttpClient,
) : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = KtorWebSocket(url, client, out)
}
}
3. Subscribe to Events (Flow API)
The simplest way to receive events — subscribeAsFlow returns a Flow<List<Event>> that accumulates events as they arrive:
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import kotlinx.coroutines.*
fun main() = runBlocking {
// 1. Create the Ktor HTTP client with WebSocket support
val httpClient = HttpClient(CIO) {
install(WebSockets)
}
// 2. Create the NostrClient
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// 3. Subscribe to text notes (kind 1) from a relay
val flow = client.subscribeAsFlow(
relay = "wss://nos.lol",
filter = Filter(kinds = listOf(1), limit = 20),
)
// 4. Collect events as they arrive
val job = launch {
flow.collect { events ->
println("Got ${events.size} events so far")
events.forEach { event ->
println(" [${event.pubKey.take(8)}...] ${event.content.take(80)}")
}
}
}
// Let it run for 10 seconds
delay(10_000)
job.cancel()
// 5. Clean up
client.disconnect()
scope.cancel()
httpClient.close()
}
4. Subscribe to Events (Callback API)
For more control, use the manual subscription API with SubscriptionListener:
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.*
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// Define a listener for subscription events
val listener = object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
val label = if (isLive) "LIVE" else "STORED"
println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}")
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
println("--- End of stored events from $relay ---")
}
}
// Build filters per relay
val filters = mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to listOf(
Filter(kinds = listOf(1), limit = 50),
),
)
// Subscribe
client.subscribe("my-feed", filters, listener)
delay(15_000)
// Unsubscribe and clean up
client.unsubscribe("my-feed")
client.disconnect()
scope.cancel()
httpClient.close()
}
5. Publish Events
Create a key pair, sign an event, and publish it to relays:
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.*
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// 1. Create a key pair (generates random keys)
val signer = NostrSignerInternal(KeyPair())
println("Public key: ${signer.pubKey}")
// 2. Build and sign a text note (kind 1)
val event = signer.sign(TextNoteEvent.build("Hello from Quartz!"))
println("Event ID: ${event.id}")
// 3. Publish and wait for relay confirmation
val accepted = client.publishAndConfirm(
event = event,
relayList = setOf(
"wss://nos.lol".normalizeRelayUrl(),
"wss://relay.damus.io".normalizeRelayUrl(),
),
)
println("Published: $accepted")
// 4. Clean up
client.disconnect()
scope.cancel()
httpClient.close()
}
For detailed per-relay results, use publishAndConfirmDetailed:
val results = client.publishAndConfirmDetailed(event, relayList)
results.forEach { (relay, success) ->
println(" $relay: ${if (success) "accepted" else "rejected"}")
}
To publish many events to one relay (a backfill, a migration), use publishStream: it keeps a
bounded window of EVENTs awaiting their OK (512 events, 8 MiB of frames by default) and hands
each verdict to a callback as it lands, with one listener for the whole stream:
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishStream
val result = client.publishStream(relay, events.asFlow(), maxInFlight = 1024) { event, ok ->
if (!ok.accepted) println("${event.id}: ${ok.message}")
}
println("${result.answered} answered, ${result.unanswered} unanswered, stalled=${result.stalled}")
When the events are already JSON (an export file, store rows), publishRawStream takes
EventWithJson(event, json) items and sends each json verbatim instead of serializing the
parsed event again; client.publishRaw(event, json, relays) is the one-event form. The JSON
must be that event's own object (same id and signature): the relay verifies what it receives.
Rate limits
A relay may refuse an EVENT or a REQ as rate-limited: and say how long to back off, in
the optional trailing params of NIP-01's OK / CLOSED (milliseconds, e.g.
["OK", id, false, "rate-limited: slow down", "1000"]). NostrClient honors it: each relay
connection (BasicRelayClient) has a RateLimitGate that holds every command of the refused
type (REQ, EVENT, or COUNT) until the back-off passes, whoever sends it — new
subscriptions and publishes, a subscription's update after its EOSE, the replay after a
reconnect. The client hands the refused REQ or EVENT straight back, and the gate queues it
first. A held REQ goes out with its subscription's filters as they are when it is released, so
changing a subscription during a back-off sends one REQ, not a stale one and then the new one. Then the held commands go out one at a time: each one the relay answers without
throttling (OK true, EOSE, a COUNT result) releases the next at once, and one it refuses
again waits out its new back-off (plus a tenth). That drains a sliding window, which
states its whole period and then takes many, in one round trip per command, and a token bucket
at its refill rate. A rate-limited: refusal without a back-off is left parked until the next
reconnect or filter change. The pool keeps each relay's gate by URL, so a back-off outlives the
relay leaving the pool and being dialed again. A RelayPool built without a scope (or a
BasicRelayClient without a RateLimitGate) honors no back-off; NostrClient always passes
its own.
publishAndCollectResults waits for that retry when it fits inside the timeout; otherwise
the relay's refusal is reported with PublishResult.retryAfterMs set. fetchAllPages keeps
waiting on the same page while the pool re-sends it. A SubscriptionListener reads the raw
value by overriding the onClosed overload that takes params (forward the three-argument
one to it too), and RetryAfter.parse(message, params) turns it into milliseconds.
On the relay side, RateLimitPolicy refuses with exactly the wait a client's token bucket
needs: build one RateLimiter per command type once, and a RateLimitPolicy(events, reqs)
per connection around them. It counts per RequestContext.clientAddress (pass it to
RelayServerBase.connect(sink, clientAddress)), or per connection when there is none. A
custom policy can state a back-off itself with PolicyResult.Rejected.rateLimited(message, retryAfterMs).
6. One-shot Reads (fetch, page, count)
Most code doesn't need a live subscription: it needs "the events matching this
filter" and then to move on. Quartz ships these as suspending extension functions
on INostrClient in com.vitorpamplona.quartz.nip01Core.relay.client.accessories.
Because they are extensions, they don't appear in NostrClient's method completion —
import them by name:
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
suspend fun readExamples(client: NostrClient, pubkey: String) {
// Every event matching the filter, deduped by id, until EOSE.
val notes = client.fetchAll("wss://nos.lol", Filter(kinds = listOf(1), authors = listOf(pubkey), limit = 50))
// The first match only; null when nothing arrives.
val profile = client.fetchFirst("wss://nos.lol", Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)))
// More than the relay's per-REQ cap (often ~500): walks a created_at cursor page by page.
val result =
client.fetchAllPages("wss://nos.lol", listOf(Filter(kinds = listOf(1), authors = listOf(pubkey)))) { event ->
println(event.id)
}
if (result.end != PagedFetchResult.End.DRAINED) println("Stopped early: ${result.end} ${result.message}")
// NIP-45 COUNT; null when the relay doesn't support it.
val followers = client.count("wss://nos.lol".normalizeRelayUrl(), Filter(kinds = listOf(3), tags = mapOf("p" to listOf(pubkey))))
println("${notes.size} notes, profile: ${profile != null}, followers: ${followers?.count}")
}
Things worth knowing:
- Every wait is an idle window (
idleTimeoutMs), reset by real progress, never a wall-clock deadline: a relay streaming a large backlog is never cut off mid-delivery. Wrap a call inwithTimeoutOrNullwhen you need a hard bound. - Only
PagedFetchResult.End.DRAINEDproves the relay has nothing more; the other endings (IDLE,CLOSED,AUTH_REQUIRED, …) say why the walk stopped, andmessagecarries the relay's own words. - A relay that requires NIP-42 AUTH is waited on while a
RelayAuthenticatorattached to the client answers the challenge, instead of being reported as empty. - There are more:
fetchAllWithHooks(verify/store each event as it arrives, with per-relay results), multi-relay paging (fetchAllPagesFromPool),countMerged, and NIP-77 negentropy sync (negentropySync,negentropySyncOrFetch). The full catalog is the accessories README.
How It Works
Architecture
┌──────────────────────────────────────────────┐
│ Your Application │
│ │
│ subscribeAsFlow() / subscribe() / publish()│
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ NostrClient │
│ │
│ PoolRequests (active subscriptions) │
│ PoolCounts (count queries) │
│ PoolEventOutbox (pending publishes) │
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ RelayPool │
│ │
│ Manages BasicRelayClient per relay URL │
│ Auto-connects/disconnects as needed │
│ Tracks connected/available relay state │
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ BasicRelayClient (per relay) │
│ │
│ Connection lifecycle + reconnection │
│ NIP-01 message parsing │
│ Exponential backoff on failures │
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ WebsocketBuilder → WebSocket │
│ │
│ KtorWebSocket (this guide) │
│ BasicOkHttpWebSocket (built-in for JVM) │
└──────────────────────────────────────────────┘
Connection Lifecycle
- You call
subscribe()orpublish()with relay URLs NostrClienttellsRelayPoolwhich relays are neededRelayPoolcreates aBasicRelayClientfor each new relayBasicRelayClientusesWebsocketBuilderto create and open aWebSocket- On connection, all pending subscriptions and events are synced automatically
- On disconnection, exponential backoff retries the connection
- On reconnection, filters are re-sent and events re-delivered
A relay stays in the pool only while something wants it: an open subscription, a pending COUNT, or an EVENT still waiting for its OK. Once none is left it is dropped at the next pool update (a few hundred ms), which is right for an app that touches many relays briefly. A caller that talks to one relay in bursts, and would otherwise redial (and re-AUTH) between them, holds it with client.keepConnected(url): the relay is dialed, kept, and redialed after it hangs up, until a matching client.releaseConnection(url). Holds are counted, so two owners of one relay each release their own.
What happens to an EVENT is the relay's answer or the connection's fate. Any OK, true or false, is the relay's verdict and ends delivery to it (only auth-required: while an AUTH can still land, and a rate-limited: that states its back-off, keep the event pending). A connection that fails, while dialing or under an open socket, is not an answer: every event still owed to that relay stays in the outbox and is re-sent on the next connection. A relay that fails ten connections in a row (about thirteen minutes, under the doubling back-off) is given up on for those events, and listeners hear onEventGaveUp (publishStream reports them as DISCONNECTED), so a publisher is not left waiting for an answer that cannot come.
NostrClient starts active by default and connects on demand — there's no need to call client.connect() at startup. That method only matters for resuming after a prior disconnect() (e.g., a user-toggled offline mode). Conversely, client.disconnect() is what you want when the app is going to background or being shut down: it tears down every socket and stops the auto-reconnect loop.
Subscription Flow
client.subscribe("my-feed", filters, listener)
│
▼
NostrClient stores filters in PoolRequests
│
▼
RelayPool connects to needed relays
│
▼
BasicRelayClient sends ["REQ", "my-feed", <filter>...]
│
▼
Relay responds with:
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
["EOSE", "my-feed"] → listener.onEose(relay)
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=true) ← new events
NIP-01 Commands
| Direction | Command | Description |
|---|---|---|
| Client → Relay | ["REQ", subId, filter...] |
Open a subscription |
| Client → Relay | ["CLOSE", subId] |
Close a subscription |
| Client → Relay | ["EVENT", event] |
Publish an event |
| Relay → Client | ["EVENT", subId, event] |
Deliver a matching event |
| Relay → Client | ["EOSE", subId] |
End of stored events |
| Relay → Client | ["OK", eventId, success, message] |
Publish acknowledgement |
| Relay → Client | ["NOTICE", message] |
Relay notice |
Filters
Filters define what events a subscription matches. All fields are optional — omitted fields match everything.
// Latest 20 text notes
Filter(kinds = listOf(1), limit = 20)
// Metadata for specific authors
Filter(
kinds = listOf(0),
authors = listOf(
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
),
)
// Events since a timestamp
Filter(kinds = listOf(1), since = 1700000000)
// Events tagged with a specific pubkey
Filter(
kinds = listOf(1),
tags = mapOf("p" to listOf("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")),
)
// Full-text search (NIP-50, relay must support it)
Filter(kinds = listOf(1), search = "bitcoin", limit = 10)
Filter Parameters
| Parameter | Type | Description |
|---|---|---|
ids |
List<HexKey>? |
Match specific event IDs (64-char hex) |
authors |
List<HexKey>? |
Match specific author pubkeys (64-char hex) |
kinds |
List<Int>? |
Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) |
tags |
Map<String, List<String>>? |
Match events with any of the listed tag values |
since |
Long? |
Events with created_at >= this Unix timestamp |
until |
Long? |
Events with created_at <= this Unix timestamp |
limit |
Int? |
Maximum number of events to return |
search |
String? |
Full-text search (NIP-50) |
Key Management
// Generate a new random key pair
val keyPair = KeyPair()
// Import an existing private key (32 bytes)
val keyPair = KeyPair(privKey = hexToByteArray("your-64-char-hex-private-key"))
// Read-only (public key only, cannot sign)
val keyPair = KeyPair(pubKey = hexToByteArray("your-64-char-hex-public-key"))
// Create a signer for signing events
val signer = NostrSignerInternal(keyPair)
Event Kinds
Common event kinds you'll work with:
| Kind | NIP | Class | Description |
|---|---|---|---|
| 0 | NIP-01 | MetadataEvent |
User metadata (name, picture, about) |
| 1 | NIP-01 | TextNoteEvent |
Text note (short post) |
| 3 | NIP-02 | ContactListEvent |
Contact list / follow list |
| 4 | NIP-04 | EncryptedDmEvent |
Encrypted direct message |
| 7 | NIP-25 | ReactionEvent |
Reaction (like, emoji) |
| 30023 | NIP-23 | LongFormContentEvent |
Long-form content (articles) |
Multi-Relay Subscriptions
Subscribe to different filters on different relays:
val filters = mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to listOf(
Filter(kinds = listOf(1), limit = 50),
),
RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf(
Filter(kinds = listOf(0, 3), authors = listOf(myPubKey)),
),
)
client.subscribe("multi-relay-feed", filters, listener)
Using OkHttp Instead of Ktor
Quartz ships with a built-in OkHttp WebSocket implementation for JVM/Android:
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient
val httpClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> httpClient }
val client = NostrClient(socketBuilder, scope)
Full Example: Simple Feed Reader
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.take
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
println("Connecting to relay...")
client.subscribeAsFlow(
relay = "wss://nos.lol",
filter = Filter(kinds = listOf(1), limit = 10),
).take(1).collect { events ->
println("\n=== Latest ${events.size} text notes ===\n")
events.forEach { event ->
println("Author: ${event.pubKey.take(16)}...")
println("Content: ${event.content.take(120)}")
println("---")
}
}
client.disconnect()
scope.cancel()
httpClient.close()
}
Key Source Files
All client infrastructure lives in the quartz module:
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
├── relay/client/
│ ├── NostrClient.kt # Main client (manages relay pool + subscriptions)
│ ├── INostrClient.kt # Client interface
│ ├── reqs/
│ │ ├── SubscriptionListener.kt # Callback interface for subscriptions
│ │ └── NostrClientSubscribeAsFlowExt.kt # Flow-based subscription API
│ └── accessories/
│ ├── NostrClientPublishExt.kt # publishAndConfirm / publishAndConfirmDetailed
│ └── NostrClientPublishStreamExt.kt # publishStream / publishRawStream: a windowed bulk publish to one relay
├── relay/normalizer/
│ ├── RelayUrlNormalizer.kt # Repairs and canonicalizes relay urls (use it, not the NormalizedRelayUrl constructor)
│ └── RelayUrlInterner.kt # One shared NormalizedRelayUrl per relay, held weakly
├── relay/sockets/
│ ├── WebSocket.kt # Transport interface
│ ├── WebSocketListener.kt # Transport callback interface
│ └── WebsocketBuilder.kt # Transport factory interface
├── relay/filters/
│ └── Filter.kt # NIP-01 subscription filters
├── crypto/
│ ├── KeyPair.kt # Schnorr key pair
│ └── Nip01Crypto.kt # Low-level crypto operations
└── signers/
├── NostrSigner.kt # Abstract signer
└── NostrSignerInternal.kt # Signs with local private key