Files
next.orly.dev/.claude/skills/ndk/ndk-skill.md
mleku 27f92336ae Add NDK skill documentation and examples
- Introduced comprehensive documentation for the Nostr Development Kit (NDK) including an overview, quick reference, and troubleshooting guide.
- Added detailed examples covering initialization, authentication, event publishing, querying, and user profile management.
- Structured the documentation to facilitate quick lookups and deep learning, based on real-world usage patterns from the Plebeian Market application.
- Created an index for examples to enhance usability and navigation.
- Bumped version to 1.0.0 to reflect the addition of this new skill set.
2025-11-06 14:34:06 +00:00

17 KiB

NDK (Nostr Development Kit) - Claude Skill Reference

Overview

NDK is the primary Nostr development kit with outbox-model support, designed for building Nostr applications with TypeScript/JavaScript. This reference is based on analyzing production usage in the Plebeian Market codebase.

Core Concepts

1. NDK Initialization

Basic Pattern:

import NDK from '@nostr-dev-kit/ndk'

// Simple initialization
const ndk = new NDK({
  explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band']
})

await ndk.connect()

Store-based Pattern (Production):

// From src/lib/stores/ndk.ts
const ndk = new NDK({
  explicitRelayUrls: relays || defaultRelaysUrls,
})

// Separate NDK for zaps on specialized relays
const zapNdk = new NDK({
  explicitRelayUrls: ZAP_RELAYS,
})

// Connect with timeout protection
const connectPromise = ndk.connect()
const timeoutPromise = new Promise((_, reject) => 
  setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
)
await Promise.race([connectPromise, timeoutPromise])

2. Authentication & Signers

NDK supports multiple signer types for different authentication methods:

NIP-07 (Browser Extension)

import { NDKNip07Signer } from '@nostr-dev-kit/ndk'

const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer

const user = await signer.user()

Private Key Signer

import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'

const signer = new NDKPrivateKeySigner(privateKeyHex)
await signer.blockUntilReady()
ndk.signer = signer

const user = await signer.user()

NIP-46 (Remote Signer / Bunker)

import { NDKNip46Signer } from '@nostr-dev-kit/ndk'

const localSigner = new NDKPrivateKeySigner(localPrivateKey)
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
ndk.signer = remoteSigner

const user = await remoteSigner.user()

Key Points:

  • Always call blockUntilReady() before using a signer
  • Store signer reference in your state management
  • Set ndk.signer to enable signing operations
  • Use await signer.user() to get the authenticated user

3. Event Creation & Publishing

Basic Event Pattern

import { NDKEvent } from '@nostr-dev-kit/ndk'

// Create event
const event = new NDKEvent(ndk)
event.kind = 1  // Kind 1 = text note
event.content = "Hello Nostr!"
event.tags = [
  ['t', 'nostr'],
  ['p', recipientPubkey]
]

// Sign and publish
await event.sign()  // Uses ndk.signer automatically
await event.publish()

// Get event ID after signing
console.log(event.id)

Production Pattern with Error Handling

// From src/publish/orders.tsx
const event = new NDKEvent(ndk)
event.kind = ORDER_PROCESS_KIND
event.content = orderNotes || ''
event.tags = [
  ['p', sellerPubkey],
  ['subject', `Order for ${productName}`],
  ['type', 'order-creation'],
  ['order', orderId],
  ['amount', totalAmount],
  ['item', productRef, quantity.toString()],
]

// Optional tags
if (shippingRef) {
  event.tags.push(['shipping', shippingRef])
}

try {
  await event.sign(signer)  // Can pass explicit signer
  await event.publish()
  return event.id
} catch (error) {
  console.error('Failed to publish event:', error)
  throw error
}

Key Points:

  • Create event with new NDKEvent(ndk)
  • Set kind, content, and tags properties
  • Optional: Set created_at timestamp (defaults to now)
  • Call await event.sign() before publishing
  • Call await event.publish() to broadcast to relays
  • Access event.id after signing for the event hash

4. Querying Events with Filters

fetchEvents() - One-time Fetch

import { NDKFilter } from '@nostr-dev-kit/ndk'

// Simple filter
const filter: NDKFilter = {
  kinds: [30402],  // Product listings
  authors: [merchantPubkey],
  limit: 50
}

const events = await ndk.fetchEvents(filter)
// Returns Set<NDKEvent>

// Convert to array and process
const eventArray = Array.from(events)
const sortedEvents = eventArray.sort((a, b) => 
  (b.created_at || 0) - (a.created_at || 0)
)

Advanced Filters

// Multiple kinds
const filter: NDKFilter = {
  kinds: [16, 17],  // Orders and payment receipts
  '#order': [orderId],  // Tag filter (# prefix)
  since: Math.floor(Date.now() / 1000) - 86400,  // Last 24 hours
  limit: 100
}

// Event ID lookup
const filter: NDKFilter = {
  ids: [eventIdHex],
}

// Tag filtering
const filter: NDKFilter = {
  kinds: [1],
  '#p': [pubkey],  // Events mentioning pubkey
  '#t': ['nostr'],  // Events with hashtag 'nostr'
}

5. Subscriptions (Real-time)

Basic Subscription

// From src/queries/blacklist.tsx
const filter = {
  kinds: [10000],
  authors: [appPubkey],
}

const subscription = ndk.subscribe(filter, {
  closeOnEose: false,  // Keep open for real-time updates
})

subscription.on('event', (event: NDKEvent) => {
  console.log('New event received:', event)
  // Process event
})

subscription.on('eose', () => {
  console.log('End of stored events')
})

// Cleanup
subscription.stop()

Production Pattern with React Query

// From src/queries/orders.tsx
useEffect(() => {
  if (!orderId || !ndk) return

  const filter = {
    kinds: [ORDER_PROCESS_KIND, PAYMENT_RECEIPT_KIND],
    '#order': [orderId],
  }

  const subscription = ndk.subscribe(filter, {
    closeOnEose: false,
  })

  subscription.on('event', (newEvent) => {
    // Invalidate React Query cache to trigger refetch
    queryClient.invalidateQueries({ 
      queryKey: orderKeys.details(orderId) 
    })
  })

  // Cleanup on unmount
  return () => {
    subscription.stop()
  }
}, [orderId, ndk, queryClient])

Monitoring Specific Events

// From src/queries/payment.tsx - Payment receipt monitoring
const receiptFilter = {
  kinds: [17],  // Payment receipts
  '#order': [orderId],
  '#payment-request': [invoiceId],
  since: sessionStartTime - 30,  // Clock skew buffer
}

const subscription = ndk.subscribe(receiptFilter, {
  closeOnEose: false,
})

subscription.on('event', (receiptEvent: NDKEvent) => {
  // Verify this is the correct invoice
  const paymentRequestTag = receiptEvent.tags.find(
    tag => tag[0] === 'payment-request'
  )
  
  if (paymentRequestTag?.[1] === invoiceId) {
    const paymentTag = receiptEvent.tags.find(tag => tag[0] === 'payment')
    const preimage = paymentTag?.[3] || 'external-payment'
    
    // Stop subscription after finding payment
    subscription.stop()
    handlePaymentReceived(preimage)
  }
})

Key Subscription Patterns:

  • Use closeOnEose: false for real-time monitoring
  • Use closeOnEose: true for one-time historical fetch
  • Always call subscription.stop() in cleanup
  • Listen to both 'event' and 'eose' events
  • Filter events in the handler for specific conditions
  • Integrate with React Query for reactive UI updates

6. User & Profile Handling

Fetching User Profiles

// From src/queries/profiles.tsx

// By npub
const user = ndk.getUser({ npub })
const profile = await user.fetchProfile()
// Returns NDKUserProfile with name, picture, about, etc.

// By hex pubkey
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()

// By NIP-05 identifier
const user = await ndk.getUserFromNip05('user@domain.com')
if (user) {
  const profile = await user.fetchProfile()
}

// Profile fields
const name = profile?.name || profile?.displayName
const avatar = profile?.picture || profile?.image
const bio = profile?.about
const nip05 = profile?.nip05
const lud16 = profile?.lud16  // Lightning address

Getting Current User

// Active user (authenticated)
const user = ndk.activeUser

// From signer
const user = await ndk.signer?.user()

// User properties
const pubkey = user.pubkey  // Hex format
const npub = user.npub      // NIP-19 encoded

7. NDK Event Object

Essential Properties

interface NDKEvent {
  id: string                 // Event hash (after signing)
  kind: number              // Event kind
  content: string           // Event content
  tags: NDKTag[]           // Array of tag arrays
  created_at?: number      // Unix timestamp
  pubkey?: string          // Author pubkey (after signing)
  sig?: string             // Signature (after signing)
  
  // Methods
  sign(signer?: NDKSigner): Promise<void>
  publish(): Promise<void>
  tagValue(tagName: string): string | undefined
}

type NDKTag = string[]  // e.g., ['p', pubkey, relay, petname]

Tag Helpers

// Get first value of a tag
const orderId = event.tagValue('order')
const recipientPubkey = event.tagValue('p')

// Find specific tag
const paymentTag = event.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3]

// Get all tags of a type
const pTags = event.tags.filter(tag => tag[0] === 'p')
const allPubkeys = pTags.map(tag => tag[1])

// Common tag patterns
event.tags.push(['p', pubkey])                    // Mention
event.tags.push(['e', eventId])                   // Reference event
event.tags.push(['t', 'nostr'])                   // Hashtag
event.tags.push(['d', identifier])                // Replaceable event ID
event.tags.push(['a', '30402:pubkey:d-tag'])     // Addressable event reference

8. Parameterized Replaceable Events (NIP-33)

Used for products, collections, profiles that need updates:

// Product listing (kind 30402)
const event = new NDKEvent(ndk)
event.kind = 30402
event.content = JSON.stringify(productDetails)
event.tags = [
  ['d', productSlug],          // Unique identifier
  ['title', productName],
  ['price', price, currency],
  ['image', imageUrl],
  ['shipping', shippingRef],
]

await event.sign()
await event.publish()

// Querying replaceable events
const filter = {
  kinds: [30402],
  authors: [merchantPubkey],
  '#d': [productSlug],  // Specific product
}

const events = await ndk.fetchEvents(filter)
// Returns only the latest version due to replaceable nature

9. Relay Management

Getting Relay Status

// From src/lib/stores/ndk.ts
const connectedRelays = Array.from(ndk.pool?.relays.values() || [])
  .filter(relay => relay.status === 1)  // 1 = connected
  .map(relay => relay.url)

const outboxRelays = Array.from(ndk.outboxPool?.relays.values() || [])

Adding Relays

// Add explicit relays
ndk.addExplicitRelay('wss://relay.example.com')

// Multiple relays
const relays = ['wss://relay1.com', 'wss://relay2.com']
relays.forEach(url => ndk.addExplicitRelay(url))

10. Common Patterns & Best Practices

Null Safety

// Always check NDK initialization
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')

// Check signer before operations requiring auth
const signer = ndk.signer
if (!signer) throw new Error('No active signer')

// Check user authentication
const user = ndk.activeUser
if (!user) throw new Error('Not authenticated')

Error Handling

try {
  const events = await ndk.fetchEvents(filter)
  if (events.size === 0) {
    return null  // No results found
  }
  return Array.from(events)
} catch (error) {
  console.error('Failed to fetch events:', error)
  throw new Error('Could not fetch data from relays')
}

Connection Lifecycle

// Initialize once at app startup
const ndk = new NDK({ explicitRelayUrls: relays })

// Connect with timeout
await Promise.race([
  ndk.connect(),
  new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Timeout')), 10000)
  )
])

// Check connection status
const isConnected = ndk.pool?.connectedRelays().length > 0

// Reconnect if needed
if (!isConnected) {
  await ndk.connect()
}

Subscription Cleanup

// In React components
useEffect(() => {
  if (!ndk) return
  
  const sub = ndk.subscribe(filter, { closeOnEose: false })
  
  sub.on('event', handleEvent)
  sub.on('eose', handleEose)
  
  // Critical: cleanup on unmount
  return () => {
    sub.stop()
  }
}, [dependencies])

Event Validation

// Check required fields before processing
if (!event.pubkey) {
  console.error('Event missing pubkey')
  return
}

if (!event.created_at) {
  console.error('Event missing timestamp')
  return
}

// Verify event age
const now = Math.floor(Date.now() / 1000)
const eventAge = now - (event.created_at || 0)
if (eventAge > 86400) {  // Older than 24 hours
  console.log('Event is old, skipping')
  return
}

// Validate specific tags exist
const orderId = event.tagValue('order')
if (!orderId) {
  console.error('Order event missing order ID')
  return
}

11. Common Event Kinds

// NIP-01: Basic Events
const KIND_METADATA = 0         // User profile
const KIND_TEXT_NOTE = 1        // Short text note
const KIND_RECOMMEND_RELAY = 2  // Relay recommendation

// NIP-04: Encrypted Direct Messages
const KIND_ENCRYPTED_DM = 4

// NIP-25: Reactions
const KIND_REACTION = 7

// NIP-51: Lists
const KIND_MUTE_LIST = 10000
const KIND_PIN_LIST = 10001
const KIND_RELAY_LIST = 10002

// NIP-57: Lightning Zaps
const KIND_ZAP_REQUEST = 9734
const KIND_ZAP_RECEIPT = 9735

// Marketplace (Plebeian/Gamma spec)
const ORDER_PROCESS_KIND = 16      // Order processing
const PAYMENT_RECEIPT_KIND = 17    // Payment receipts
const DIRECT_MESSAGE_KIND = 14     // Direct messages
const ORDER_GENERAL_KIND = 27      // General order events
const SHIPPING_KIND = 30405        // Shipping options
const PRODUCT_KIND = 30402         // Product listings
const COLLECTION_KIND = 30401      // Product collections
const REVIEW_KIND = 30407          // Product reviews

// Application Handlers
const APP_HANDLER_KIND = 31990     // NIP-89 app handlers

Integration with TanStack Query

NDK works excellently with TanStack Query for reactive data fetching:

Query Functions

// From src/queries/products.tsx
export const fetchProductsByPubkey = async (pubkey: string) => {
  const ndk = ndkActions.getNDK()
  if (!ndk) throw new Error('NDK not initialized')

  const filter: NDKFilter = {
    kinds: [30402],
    authors: [pubkey],
  }

  const events = await ndk.fetchEvents(filter)
  return Array.from(events).map(parseProductEvent)
}

export const useProductsByPubkey = (pubkey: string) => {
  return useQuery({
    queryKey: productKeys.byAuthor(pubkey),
    queryFn: () => fetchProductsByPubkey(pubkey),
    enabled: !!pubkey,
    staleTime: 30000,
  })
}

Combining Queries with Subscriptions

// Query for initial data
const { data: order, refetch } = useQuery({
  queryKey: orderKeys.details(orderId),
  queryFn: () => fetchOrderById(orderId),
  enabled: !!orderId,
})

// Subscription for real-time updates
useEffect(() => {
  if (!orderId || !ndk) return

  const sub = ndk.subscribe(
    { kinds: [16, 17], '#order': [orderId] },
    { closeOnEose: false }
  )

  sub.on('event', () => {
    // Invalidate query to trigger refetch
    queryClient.invalidateQueries({ 
      queryKey: orderKeys.details(orderId) 
    })
  })

  return () => sub.stop()
}, [orderId, ndk, queryClient])

Troubleshooting

Events Not Received

  • Check relay connections: ndk.pool?.connectedRelays()
  • Verify filter syntax (especially tag filters with # prefix)
  • Check event timestamps match filter's since/until
  • Ensure closeOnEose: false for real-time subscriptions

Signing Errors

  • Verify signer is initialized: await signer.blockUntilReady()
  • Check signer is set: ndk.signer !== undefined
  • For NIP-07, ensure browser extension is installed and enabled
  • For NIP-46, verify bunker URL and local signer are correct

Connection Timeouts

  • Implement connection timeout pattern shown above
  • Try connecting to fewer, more reliable relays initially
  • Use fallback relays in production

Duplicate Events

  • NDK deduplicates by event ID automatically
  • For subscriptions, track processed event IDs if needed
  • Use replaceable events (kinds 10000-19999, 30000-39999) when appropriate

Performance Optimization

Batching Queries

// Instead of multiple fetchEvents calls
const [products, orders, profiles] = await Promise.all([
  ndk.fetchEvents(productFilter),
  ndk.fetchEvents(orderFilter),
  ndk.fetchEvents(profileFilter),
])

Limiting Results

const filter = {
  kinds: [1],
  authors: [pubkey],
  limit: 50,  // Limit results
  since: recentTimestamp,  // Only recent events
}

Caching with React Query

export const useProfile = (npub: string) => {
  return useQuery({
    queryKey: profileKeys.byNpub(npub),
    queryFn: () => fetchProfileByNpub(npub),
    staleTime: 5 * 60 * 1000,  // 5 minutes
    cacheTime: 30 * 60 * 1000,  // 30 minutes
    enabled: !!npub,
  })
}

References

Key Files in This Codebase

  • src/lib/stores/ndk.ts - NDK store and initialization
  • src/lib/stores/auth.ts - Authentication with NDK signers
  • src/queries/*.tsx - Query patterns with NDK
  • src/publish/*.tsx - Event publishing patterns
  • scripts/gen_*.ts - Event creation examples

This reference is based on NDK version used in production and real-world patterns from the Plebeian Market application.