Agent Skills: Nostr Protocol Expert (Quartz Implementation)

Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.

UncategorizedID: vitorpamplona/amethyst/nostr-expert

Install this agent skill to your local

pnpm dlx add-skill https://github.com/vitorpamplona/amethyst/tree/HEAD/.claude/skills/nostr-expert

Skill Files

Browse the full folder contents for nostr-expert.

Download Skill

Loading file tree…

.claude/skills/nostr-expert/SKILL.md

Skill Metadata

Name
nostr-expert
Description
Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.

Nostr Protocol Expert (Quartz Implementation)

Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library.

When to Use This Skill

  • Implementing Nostr event types (TextNote, Reaction, Zap, etc.)
  • Creating/parsing events with TagArrayBuilder DSL
  • Working with event kinds and tags
  • Finding NIP implementations in quartz/ codebase
  • Nostr cryptography (secp256k1 signing, NIP-44 encryption)
  • Bech32 encoding/decoding (npub, nsec, note formats)
  • Resolving user input (hex / npub / nprofile / NIP-05 name@domain) to a pubkey
  • Event validation and verification

For NIP specifications → Use nostr-protocol agent For Quartz implementation → Use this skill

Quartz Architecture

Quartz organizes code by NIP number:

quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/
├── nip01Core/           # Core protocol (Event, Kind, Tags)
├── nip04Dm/             # Legacy DMs (deprecated)
├── nip10Notes/          # Text notes with threading
├── nip17Dm/             # Private DMs (gift wrap)
├── nip19Bech32/         # Bech32 encoding
├── nip44Encryption/     # Modern encryption (ChaCha20)
├── nip57Zaps/           # Lightning zaps
├── ... (57 NIPs total)
└── experimental/        # Draft NIPs

Pattern: nip##<Name>/ directories contain event classes, tags, and utilities for that NIP.

Find implementations: Use scripts/nip-lookup.sh <nip-number> or see references/nip-catalog.md.

Event Anatomy

Core Structure

@Immutable
open class Event(
    val id: HexKey,              // SHA-256 hash of serialized event
    val pubKey: HexKey,          // Author's public key (32 bytes hex)
    val createdAt: Long,         // Unix timestamp
    val kind: Kind,              // Event kind (Int typealias)
    val tags: TagArray,          // Array of tag arrays
    val content: String,         // Event content
    val sig: HexKey,             // Schnorr signature (64 bytes hex)
) : IEvent

Key insight: Event is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods.

Kind Classification

typealias Kind = Int

fun Kind.isEphemeral() = this in 20000..29999      // Not stored
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
fun Kind.isAddressable() = this in 30000..39999    // Replaceable + has d-tag
fun Kind.isRegular() = this in 1000..9999          // Stored, not replaced

Pattern: Kind determines event lifecycle and replaceability.

Creating Events

EventTemplate Pattern

fun eventTemplate(
    kind: Kind,
    content: String,
    tags: TagArray = emptyArray()
): EventTemplate

Usage:

val template = eventTemplate(
    kind = 1,  // Text note
    content = "Hello Nostr!",
    tags = tagArray {
        add(arrayOf("subject", "Greeting"))
    }
)

// Sign with a signer
val signedEvent = signer.sign(template)

Why templates? Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets).

TagArrayBuilder DSL

fun <T : Event> tagArray(
    initializer: TagArrayBuilder<T>.() -> Unit
): TagArray

Methods:

  • add(tag) - Append tag
  • addFirst(tag) - Prepend tag (for ordering)
  • addUnique(tag) - Replace all tags with this name
  • remove(tagName) - Remove by name
  • addAll(tags) - Bulk add

Example:

val tags = tagArray<TextNoteEvent> {
    add(arrayOf("e", replyToEventId, "", "reply"))
    add(arrayOf("p", authorPubkey))
    addUnique(arrayOf("subject", "Re: Hello"))
    add(arrayOf("content-warning", "spoilers"))
}

Pattern: Fluent DSL for building tag arrays with validation and deduplication.

Common Event Types

TextNoteEvent (kind 1)

class TextNoteEvent : BaseThreadedEvent

Creating:

val note = eventTemplate(
    kind = 1,
    content = "Hello world!",
    tags = tagArray {
        add(arrayOf("subject", "First post"))
    }
)

Parsing:

val event: TextNoteEvent = ...
val subject = event.subject()  // Extension from nip14Subject
val mentions = event.mentions()  // List of p-tags
val quotedEvents = event.quotes()  // List of q-tags

ReactionEvent (kind 7)

fun createReaction(
    targetEvent: Event,
    emoji: String = "+"
): EventTemplate {
    return eventTemplate(
        kind = 7,
        content = emoji,
        tags = tagArray {
            add(arrayOf("e", targetEvent.id))
            add(arrayOf("p", targetEvent.pubKey))
        }
    )
}

MetadataEvent (kind 0)

data class UserMetadata(
    val name: String?,
    val displayName: String?,
    val picture: String?,
    val banner: String?,
    val about: String?,
    // ... more fields
)

fun createMetadata(metadata: UserMetadata): EventTemplate {
    return eventTemplate(
        kind = 0,
        content = metadata.toJson()  // Serialize to JSON
    )
}

Addressable Events (kinds 30000-40000)

fun createArticle(
    slug: String,
    title: String,
    content: String
): EventTemplate {
    return eventTemplate(
        kind = 30023,
        content = content,
        tags = tagArray {
            addUnique(arrayOf("d", slug))  // Unique identifier
            add(arrayOf("title", title))
            add(arrayOf("published_at", "${TimeUtils.now()}"))
        }
    )
}

Key: d-tag makes it addressable. Events with same kind + pubkey + d-tag replace each other.

Tag Patterns

Tags are Array<String> with pattern [name, value, ...optionalParams].

Core Tags

e-tag (event reference):

add(arrayOf("e", eventId, relayHint, marker))
// marker: "reply", "root", "mention"

p-tag (pubkey reference):

add(arrayOf("p", pubkey, relayHint))

a-tag (addressable event):

add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint))

d-tag (identifier for addressable events):

addUnique(arrayOf("d", "unique-slug"))

Tag Extensions

// Find tags
event.tags.tagValue("subject")  // First subject tag value
event.tags.allTags("p")  // All p-tags
event.tags.tagValues("e")  // All e-tag values

// Parse structured tags
event.tags.mapNotNull(ETag::parse)  // Parse as ETag objects

For comprehensive tag patterns, see references/tag-patterns.md.

Threading (NIP-10)

fun createReply(
    original: TextNoteEvent,
    content: String
): EventTemplate {
    return eventTemplate(
        kind = 1,
        content = content,
        tags = tagArray {
            // Reply marker
            add(arrayOf("e", original.id, "", "reply"))

            // Root marker (original's root, or original itself)
            original.rootEvent()?.let {
                add(arrayOf("e", it.id, "", "root"))
            } ?: add(arrayOf("e", original.id, "", "root"))

            // Tag author
            add(arrayOf("p", original.pubKey))

            // Tag all mentioned users
            original.mentions().forEach {
                add(arrayOf("p", it))
            }
        }
    )
}

Pattern: reply and root markers establish thread hierarchy.

Cryptography

Signing (secp256k1)

interface ISigner {
    suspend fun sign(template: EventTemplate): Event
}

// Local key signing
class LocalSigner(private val privateKey: ByteArray) : ISigner {
    override suspend fun sign(template: EventTemplate): Event {
        val id = template.generateId()
        val sig = Secp256k1.sign(id, privateKey)
        return Event(id, pubKey, createdAt, kind, tags, content, sig)
    }
}

Pattern: Signers abstract key management. Can be local, remote (NIP-46), or hardware.

Encryption (NIP-44)

// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade
// (nip44Encryption/Nip44.kt — picks the current version, decrypts any)
object Nip44 {
    fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo
    fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String
}

// Usage
val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey)
val payload = encrypted.encodePayload()  // base64 string for event content

val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey)

Most code should not call Nip44 directly — go through signer.nip44Encrypt(plaintext, toPublicKey) / signer.nip44Decrypt(ciphertext, fromPublicKey) so remote/external signers keep working.

Pattern: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.

NIP-04 (Deprecated)

// Legacy encryption (NIP-04, deprecated for NIP-44)
object Nip04 {
    fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
    fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
}

Note: Use NIP-44 (Nip44) for new implementations. NIP-04 has security issues.

Hex Encoding (HexKey ↔ ByteArray)

Pubkeys, event ids and signatures are lower-case hex. Quartz uses the HexKey typealias (= String) plus extensions in nip01Core/core/HexKey.kt, backed by the Hex object in utils/Hex.kt. Use these — never hand-roll a byte loop or import a third-party hex codec.

import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex

val hex: HexKey = bytes.toHexKey()            // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray()    // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull()  // null on invalid hex

Hex.isHex(input)     // valid hex, any length
Hex.isHex64(input)   // ~30% faster fast-path for a 32-byte key/id
hex.isValid()        // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes)  // compare hex to bytes without decoding

Constants PUBKEY_LENGTH / EVENT_ID_LENGTH (both 64) live in nip01Core.core.

Core Utilities (time, random, event id)

Reuse these instead of hand-rolling — each avoids a common mistake:

import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher

TimeUtils.now()            // Unix SECONDS for created_at — not currentTimeMillis()/1000
TimeUtils.oneHourAgo()     // relative filter bounds (…Ago / …FromNow); all in seconds
RandomInstance.bytes(32)   // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
RandomInstance.randomChars()   // 16-char subscription id

sha256(bytes)              // raw hash primitive
EventHasher.hashId(pubKey, createdAt, kind, tags, content)   // canonical event id
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)  // verify untrusted events

EventHasher serializes [0, pubkey, created_at, kind, tags, content] in the exact form NIP-01 requires — prefer it over calling sha256 on your own JSON.

Bech32 Encoding (NIP-19)

Encoding uses extension functions on ByteArray (nip19Bech32/ByteArrayExt.kt); TLV entities carry relay hints via create() helpers on the entity classes in nip19Bech32/entities/. Decoding goes through Nip19Parser, whose uriToRoute() returns a ParseReturn? wrapping the parsed Entity.

// Encode simple entities: ByteArray extensions
val npub = pubkeyBytes.toNpub()   // "npub1..."
val nsec = privKeyBytes.toNsec()  // "nsec1..."
val note = eventIdBytes.toNote()  // "note1..."

// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
val nprofile = NProfile.create(pubkeyHex, relays)

Usage:

// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
    is NPub -> println("Pubkey: ${entity.hex}")
    is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}")
    is NAddress -> println("Address: ${entity.aTag()}")
    null -> println("not a valid bech32 entity")
    else -> println("Other type")
}

Resolving User Input to a Pubkey (NIP-05 + NIP-19)

Before writing any if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown() logic, stop — it already exists. resolveUserHexOrNull in quartz/nip05DnsIdentifiers/ accepts every identifier form a user might type and returns a 64-hex pubkey.

import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull

// hex | npub1… | nprofile1… | nsec1… | name@domain.tld  →  HexKey? (null if unrecognized/lookup fails)
val pubkey = resolveUserHexOrNull(userInput, nip05Client)
  • Tries the synchronous hex/bech32 path first (decodePublicKeyAsHexOrNull) — only NIP-05-shaped input hits the network.
  • suspend; re-throws only CancellationException. Pass nip05Client = null for offline contexts.
  • Build the client with Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp }) (see cli/Context.kt). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the .well-known/nostr.json fetch or JSON parse.
  • Need only hex/bech32 (no network)? Use decodePublicKeyAsHexOrNull(input) directly.
  • Need to verify a claimed identifier maps back to a pubkey? nip05Client.verify(Nip05Id.parse(id)!!, pubkey).

See references/nip05-identifiers.md for the full API surface (Nip05Id, Nip05Client, Nip05Parser, KeyInfoSet, Namecoin .bit) and the hand-rolled anti-pattern to avoid.

Event Validation

fun Event.verify(): Boolean {
    // 1. Verify ID matches content hash
    val computedId = generateId()
    if (id != computedId) return false

    // 2. Verify signature
    return Secp256k1.verify(id, sig, pubKey)
}

fun Event.generateId(): HexKey {
    val serialized = serializeForId()  // JSON array format
    return sha256(serialized)
}

Pattern: Always verify events from untrusted sources (relays).

Common Workflows

Publishing an Event

suspend fun publishNote(content: String, signer: ISigner, relays: List<String>) {
    // 1. Create template
    val template = eventTemplate(kind = 1, content = content)

    // 2. Sign
    val event = signer.sign(template)

    // 3. Verify (optional but recommended)
    require(event.verify()) { "Signature verification failed" }

    // 4. Publish to relays
    relays.forEach { relay ->
        relayClient.send(relay, event)
    }
}

Querying Events

// Subscription filter
data class Filter(
    val ids: List<HexKey>? = null,
    val authors: List<HexKey>? = null,
    val kinds: List<Kind>? = null,
    val since: Long? = null,
    val until: Long? = null,
    val limit: Int? = null,
    val tags: Map<String, List<String>>? = null  // e.g., {"#e": [eventId], "#p": [pubkey]}
)

// Usage
val filter = Filter(
    authors = listOf(userPubkey),
    kinds = listOf(1),  // Text notes only
    limit = 50
)

relayClient.subscribe(relay, filter) { event ->
    // Handle incoming events
}

Creating a Zap (NIP-57)

fun createZapRequest(
    targetEvent: Event,
    amountSats: Long,
    comment: String = ""
): EventTemplate {
    return eventTemplate(
        kind = 9734,  // Zap request
        content = comment,
        tags = tagArray {
            add(arrayOf("e", targetEvent.id))
            add(arrayOf("p", targetEvent.pubKey))
            add(arrayOf("amount", "${amountSats * 1000}"))  // millisats
            add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com"))
        }
    )
}

Gift-Wrapped DMs (NIP-17)

fun createGiftWrappedDM(
    recipientPubkey: HexKey,
    message: String,
    signer: ISigner
): Event {
    // 1. Create sealed gossip (kind 14)
    val sealedGossip = createSealedGossip(message, recipientPubkey, signer)

    // 2. Wrap in gift wrap (kind 1059)
    return createGiftWrap(sealedGossip, recipientPubkey, signer)
}

Pattern: Double encryption + random ephemeral keys for metadata protection.

Finding NIPs

Use the bundled script:

# Find by NIP number
scripts/nip-lookup.sh 44

# Search by term
scripts/nip-lookup.sh encryption
scripts/nip-lookup.sh "gift wrap"

Or see references/nip-catalog.md for complete catalog.

Bundled Resources

  • references/nip-catalog.md - All 57 NIPs with package locations and key files
  • references/event-hierarchy.md - Event class hierarchy, kind classifications, common types
  • references/tag-patterns.md - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
  • references/nip19-bech32.md - Nip19Parser, Bech32Util, TlvBuilder, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
  • references/nip05-identifiers.md - Resolving any identifier (hex/npub/nprofile/nsec/name@domain) to a pubkey via resolveUserHexOrNull; Nip05Client, Nip05Id, Nip05Parser, Namecoin .bit — and the hand-rolled anti-pattern to avoid
  • references/event-factory.md - EventFactory dispatch pattern and how to register a new kind
  • references/crypto-and-encryption.md - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, SharedKeyCache
  • references/large-cache.md - LargeCache<K,V> expect/actual + ICacheOperations functional API
  • scripts/nip-lookup.sh - Find NIP implementations by number or search term

Quick Reference

| Task | Pattern | Location | |------|---------|----------| | Create event | eventTemplate(kind, content, tags) | nip01Core/signers/ | | Build tags | tagArray { add(...) } | nip01Core/core/ | | Sign event | signer.sign(template) | nip01Core/signers/ | | Verify signature | event.verify() | nip01Core/core/ | | Encrypt (NIP-44) | Nip44v2.encrypt(...) | nip44Encryption/ | | Bech32 encode | Nip19.npubEncode(...) | nip19Bech32/ | | Resolve input → pubkey | resolveUserHexOrNull(input, nip05Client) | nip05DnsIdentifiers/ | | Decode bech32 → pubkey (no net) | decodePublicKeyAsHexOrNull(input) | nip19Bech32/ | | Verify NIP-05 identifier | nip05Client.verify(Nip05Id.parse(id)!!, hex) | nip05DnsIdentifiers/ | | Find NIP | scripts/nip-lookup.sh <number> | - |

Common Event Kinds

| Kind | Type | NIP | Package | |------|------|-----|---------| | 0 | Metadata | 01 | nip01Core/ | | 1 | Text note | 01, 10 | nip10Notes/ | | 3 | Contact list | 02 | nip02FollowList/ | | 5 | Deletion | 09 | nip09Deletions/ | | 7 | Reaction | 25 | nip25Reactions/ | | 1059 | Gift wrap | 59 | nip59Giftwrap/ | | 9734 | Zap request | 57 | nip57Zaps/ | | 9735 | Zap receipt | 57 | nip57Zaps/ | | 10002 | Relay list | 65 | nip65RelayList/ | | 30023 | Long-form content | 23 | nip23LongContent/ |

Related Skills

  • nostr-protocol - NIP specifications and protocol details
  • kotlin-expert - Kotlin patterns (@Immutable, sealed classes, DSLs)
  • kotlin-coroutines - Async patterns for relay communication
  • kotlin-multiplatform - KMP patterns, expect/actual in Quartz